mirror of
https://github.com/offen/website.git
synced 2024-11-25 10:10:28 +01:00
Merge pull request #98 from offen/local-proxy
consolidate application behind a single domain
This commit is contained in:
commit
8182840f02
@ -1,69 +1,32 @@
|
||||
version: 2
|
||||
|
||||
production_env: &production_env
|
||||
environment:
|
||||
- SERVER_HOST=https://server-alpha.offen.dev
|
||||
- KMS_HOST=https://kms-alpha.offen.dev
|
||||
- SCRIPT_HOST=https://script-alpha.offen.dev
|
||||
- AUDITORIUM_HOST=https://auditorium-alpha.offen.dev
|
||||
- VAULT_HOST=https://vault-alpha.offen.dev
|
||||
- ACCOUNTS_HOST=https://accounts-alpha.offen.dev
|
||||
- HOMEPAGE_HOST=https://www.offen.dev
|
||||
- NODE_ENV=production
|
||||
- SECRET_ID_SERVER_CONNECTION_STRING=alpha/server/postgresConnectionString
|
||||
|
||||
deploy_preconditions: &deploy_preconditions
|
||||
build_preconditions: &build_preconditions
|
||||
requires:
|
||||
- server
|
||||
- kms
|
||||
- vault
|
||||
- script
|
||||
- auditorium
|
||||
- packages
|
||||
- shared
|
||||
- accounts
|
||||
filters:
|
||||
branches:
|
||||
only: /^master$/
|
||||
|
||||
build_preconditions: &build_preconditions
|
||||
deploy_preconditions: &deploy_preconditions
|
||||
requires:
|
||||
- build
|
||||
filters:
|
||||
branches:
|
||||
ignore: gh-pages
|
||||
only: /^master$/
|
||||
|
||||
jobs:
|
||||
kms:
|
||||
docker:
|
||||
- image: circleci/golang:1.12
|
||||
environment:
|
||||
- PORT=8081
|
||||
working_directory: ~/offen/kms
|
||||
steps:
|
||||
- checkout:
|
||||
path: ~/offen
|
||||
- restore_cache:
|
||||
key: offen-kms-{{ checksum "go.mod" }}
|
||||
- run:
|
||||
name: Download modules
|
||||
command: go mod download
|
||||
- save_cache:
|
||||
paths:
|
||||
- /go/pkg/mod
|
||||
key: offen-kms-{{ checksum "go.mod" }}
|
||||
- run:
|
||||
name: Generate one-off key file
|
||||
command: make bootstrap
|
||||
- run:
|
||||
name: Run tests
|
||||
command: make test-ci
|
||||
|
||||
server:
|
||||
docker:
|
||||
- image: circleci/golang:1.12
|
||||
environment:
|
||||
- POSTGRES_CONNECTION_STRING=postgres://circle:test@localhost:5432/circle_test?sslmode=disable
|
||||
- PORT=8080
|
||||
- EVENT_RETENTION_PERIOD=4464h
|
||||
POSTGRES_CONNECTION_STRING: postgres://circle:test@localhost:5432/circle_test?sslmode=disable
|
||||
PORT: 8080
|
||||
EVENT_RETENTION_PERIOD: 4464h
|
||||
COOKIE_EXCHANGE_SECRET: VswgMshC4mPDfey8o+yScg==
|
||||
- image: circleci/postgres:11.2-alpine
|
||||
environment:
|
||||
- POSTGRES_USER=circle
|
||||
@ -97,20 +60,6 @@ jobs:
|
||||
cp ~/offen/bootstrap.yml .
|
||||
make test-ci
|
||||
|
||||
shared:
|
||||
docker:
|
||||
- image: circleci/golang:1.12
|
||||
working_directory: ~/offen/shared
|
||||
steps:
|
||||
- checkout:
|
||||
path: ~/offen
|
||||
- run:
|
||||
name: Install dependencies
|
||||
command: go get ./...
|
||||
- run:
|
||||
name: Run tests
|
||||
command: make test
|
||||
|
||||
vault:
|
||||
docker:
|
||||
- image: circleci/node:10-browsers
|
||||
@ -200,262 +149,75 @@ jobs:
|
||||
name: Run tests
|
||||
command: npm test
|
||||
|
||||
accounts:
|
||||
build:
|
||||
docker:
|
||||
- image: docker:18-git
|
||||
environment:
|
||||
SITEURL: https://www.offen.dev
|
||||
DOCKER_TAGE: stable
|
||||
working_directory: ~/offen
|
||||
steps:
|
||||
- checkout
|
||||
- setup_remote_docker
|
||||
- restore_cache:
|
||||
keys:
|
||||
- v1-{{ .Branch }}
|
||||
paths:
|
||||
- /caches/proxy.tar
|
||||
- /caches/server.tar
|
||||
- run:
|
||||
name: Load Docker image layer cache
|
||||
command: |
|
||||
set +o pipefail
|
||||
docker load -i /caches/app.tar | true
|
||||
- run:
|
||||
name: Build application Docker image
|
||||
command: |
|
||||
docker build --cache-from=offen/proxy -t offen/proxy:latest -f build/proxy/Dockerfile .
|
||||
docker build --cache-from=offen/server -t offen/server:latest -f build/server/Dockerfile .
|
||||
- run:
|
||||
name: Save Docker image layer cache
|
||||
command: |
|
||||
mkdir -p /caches
|
||||
docker save -o /caches/proxy.tar offen/proxy
|
||||
docker save -o /caches/server.tar offen/server
|
||||
- save_cache:
|
||||
key: v1-{{ .Branch }}-{{ epoch }}
|
||||
paths:
|
||||
- /caches/app.tar
|
||||
- deploy:
|
||||
name: Push application Docker image
|
||||
command: |
|
||||
echo "$DOCKER_PASS" | docker login --username $DOCKER_USER --password-stdin
|
||||
docker push offen/server:latest
|
||||
docker push offen/proxy:latest
|
||||
|
||||
deploy:
|
||||
working_directory: ~/offen
|
||||
docker:
|
||||
- image: circleci/python:3.6
|
||||
environment:
|
||||
CONFIG_CLASS: accounts.config.LocalConfig
|
||||
MYSQL_CONNECTION_STRING: mysql://root:circle@127.0.0.1:3306/circle
|
||||
- image: circleci/mysql:5.7
|
||||
environment:
|
||||
- MYSQL_ROOT_PASSWORD=circle
|
||||
- MYSQL_DATABASE=circle
|
||||
- MYSQL_HOST=127.0.0.1
|
||||
working_directory: ~/offen/accounts
|
||||
steps:
|
||||
- checkout:
|
||||
path: ~/offen
|
||||
- restore_cache:
|
||||
key: offen-accounts-{{ checksum "requirements.txt" }}-{{ checksum "requirements-dev.txt" }}
|
||||
- checkout
|
||||
- run:
|
||||
name: Install dependencies
|
||||
name: Installing deployment dependencies
|
||||
command: |
|
||||
python3 -m venv venv
|
||||
. venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
pip install -r requirements-dev.txt
|
||||
- save_cache:
|
||||
paths:
|
||||
- ~/offen/accounts/venv
|
||||
key: offen-accounts-{{ checksum "requirements.txt" }}-{{ checksum "requirements-dev.txt" }}
|
||||
sudo pip install awsebcli --upgrade
|
||||
- run:
|
||||
name: Waiting for MySQL to be ready
|
||||
name: Deploying
|
||||
command: |
|
||||
for i in `seq 1 10`;
|
||||
do
|
||||
nc -z localhost 3306 && echo Success && exit 0
|
||||
echo -n .
|
||||
sleep 1
|
||||
done
|
||||
echo Failed waiting for MySQL && exit 1
|
||||
- run:
|
||||
name: Run tests
|
||||
command: |
|
||||
. venv/bin/activate
|
||||
cp ~/offen/bootstrap.yml .
|
||||
make test-ci
|
||||
|
||||
deploy_python:
|
||||
docker:
|
||||
- image: circleci/python:3.6-node
|
||||
<<: *production_env
|
||||
working_directory: ~/offen
|
||||
steps:
|
||||
- checkout:
|
||||
path: ~/offen
|
||||
- restore_cache:
|
||||
key: offen-deploy-{{ checksum "package.json" }}
|
||||
- run:
|
||||
name: Install dependencies
|
||||
command: npm install
|
||||
- save_cache:
|
||||
paths:
|
||||
- ~/offen/packages/node_modules
|
||||
key: offen-packages-{{ checksum "package.json" }}
|
||||
- run:
|
||||
name: Deploy
|
||||
working_directory: ~/offen/accounts
|
||||
command: |
|
||||
echo "Deploying accounts ..."
|
||||
$(npm bin)/sls deploy
|
||||
|
||||
deploy_golang:
|
||||
docker:
|
||||
- image: circleci/golang:1.12-node
|
||||
<<: *production_env
|
||||
working_directory: ~/offen
|
||||
steps:
|
||||
- checkout:
|
||||
path: ~/offen
|
||||
- restore_cache:
|
||||
key: offen-deploy-{{ checksum "package.json" }}
|
||||
- run:
|
||||
name: Install dependencies
|
||||
command: npm install
|
||||
- save_cache:
|
||||
paths:
|
||||
- ~/offen/packages/node_modules
|
||||
key: offen-packages-{{ checksum "package.json" }}
|
||||
|
||||
- restore_cache:
|
||||
key: offen-server-{{ checksum "server/go.mod" }}
|
||||
- run:
|
||||
name: Build server service
|
||||
working_directory: ~/offen/server
|
||||
command: make build
|
||||
- run:
|
||||
name: Manually clear go cache
|
||||
command: sudo rm -rf /go/pkg/mod
|
||||
|
||||
- restore_cache:
|
||||
key: offen-kms-{{ checksum "kms/go.mod" }}
|
||||
- run:
|
||||
name: Build kms service
|
||||
working_directory: ~/offen/kms
|
||||
command: make build
|
||||
- run:
|
||||
name: Manually clear go cache
|
||||
command: sudo rm -rf /go/pkg/mod
|
||||
|
||||
- run:
|
||||
name: Migrate `server` database
|
||||
working_directory: ~/offen/server
|
||||
command: |
|
||||
sudo apt-get update && sudo apt-get install -qq -y python-pip libpython-dev
|
||||
curl -O https://bootstrap.pypa.io/get-pip.py && sudo python get-pip.py
|
||||
sudo pip install -q awscli --upgrade
|
||||
go run cmd/migrate/main.go -conn $(aws secretsmanager get-secret-value --secret-id $SECRET_ID_SERVER_CONNECTION_STRING | jq -r '.SecretString')
|
||||
- run:
|
||||
name: Deploy
|
||||
working_directory: ~/offen
|
||||
command: |
|
||||
echo "Deploying server ..."
|
||||
$(npm bin)/sls deploy --config server/serverless.yml
|
||||
echo "Deploying kms ..."
|
||||
$(npm bin)/sls deploy --config kms/serverless.yml
|
||||
|
||||
deploy_node:
|
||||
docker:
|
||||
- image: circleci/node:10
|
||||
<<: *production_env
|
||||
working_directory: ~/offen
|
||||
steps:
|
||||
- checkout:
|
||||
path: ~/offen
|
||||
- restore_cache:
|
||||
key: offen-deploy-{{ checksum "package.json" }}
|
||||
- run:
|
||||
name: Install dependencies
|
||||
command: npm install
|
||||
- save_cache:
|
||||
paths:
|
||||
- ~/offen/packages/node_modules
|
||||
key: offen-packages-{{ checksum "package.json" }}
|
||||
- restore_cache:
|
||||
key: offen-auditorium-{{ checksum "auditorium/package.json" }}
|
||||
- run:
|
||||
name: Build auditorium service
|
||||
working_directory: ~/offen/auditorium
|
||||
command: npm run build
|
||||
|
||||
- restore_cache:
|
||||
key: offen-script-{{ checksum "script/package.json" }}
|
||||
- run:
|
||||
name: Build script service
|
||||
working_directory: ~/offen/script
|
||||
command: npm run build
|
||||
|
||||
- restore_cache:
|
||||
key: offen-vault-{{ checksum "vault/package.json" }}
|
||||
- run:
|
||||
name: Build vault service
|
||||
working_directory: ~/offen/vault
|
||||
command: npm run build
|
||||
|
||||
- run:
|
||||
name: Deploy
|
||||
working_directory: ~/offen
|
||||
command: |
|
||||
echo "Deploying auditorium ..."
|
||||
$(npm bin)/sls deploy --config auditorium/serverless.yml
|
||||
$(npm bin)/sls client deploy --config auditorium/serverless.yml --no-confirm
|
||||
echo "Deploying script ..."
|
||||
$(npm bin)/sls deploy --config script/serverless.yml
|
||||
$(npm bin)/sls client deploy --config script/serverless.yml --no-confirm
|
||||
echo "Deploying vault ..."
|
||||
$(npm bin)/sls deploy --config vault/serverless.yml
|
||||
$(npm bin)/sls client deploy --config vault/serverless.yml --no-confirm
|
||||
|
||||
deploy_homepage:
|
||||
docker:
|
||||
- image: circleci/python:3.6-node
|
||||
working_directory: ~/offen/homepage
|
||||
environment:
|
||||
- SOURCE_BRANCH: master
|
||||
- TARGET_BRANCH: gh-pages
|
||||
steps:
|
||||
- checkout:
|
||||
path: ~/offen
|
||||
- restore_cache:
|
||||
key: offen-homepage-{{ checksum "requirements.txt" }}
|
||||
- run:
|
||||
name: Install dependencies
|
||||
command: |
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
- save_cache:
|
||||
paths:
|
||||
- ~/offen/homepage/venv
|
||||
key: offen-homepage-{{ checksum "requirements.txt" }}
|
||||
- run:
|
||||
name: Install image optimization deps
|
||||
command: |
|
||||
sudo npm install svgo -g
|
||||
sudo apt-get install libjpeg-progs optipng
|
||||
- run:
|
||||
name: Deploy
|
||||
command: |
|
||||
source venv/bin/activate
|
||||
|
||||
git config --global user.email $GH_EMAIL
|
||||
git config --global user.name $GH_NAME
|
||||
|
||||
git clone $CIRCLE_REPOSITORY_URL out
|
||||
|
||||
cd out
|
||||
git checkout $TARGET_BRANCH || git checkout --orphan $TARGET_BRANCH
|
||||
git rm -rf .
|
||||
cd ..
|
||||
|
||||
make publish
|
||||
|
||||
cp -a output/. out/.
|
||||
|
||||
mkdir -p out/.circleci && cp -a ./../.circleci/. out/.circleci/.
|
||||
cp CNAME out/CNAME
|
||||
cd out
|
||||
|
||||
git add -A
|
||||
git commit -m "Automated deployment to GitHub Pages: ${CIRCLE_SHA1}" --allow-empty
|
||||
|
||||
git push origin $TARGET_BRANCH
|
||||
cp Dockerrun.aws.json.production Dockerrun.aws.json
|
||||
eb deploy
|
||||
|
||||
workflows:
|
||||
version: 2
|
||||
test_build_deploy:
|
||||
jobs:
|
||||
- server:
|
||||
- server
|
||||
- vault
|
||||
- script
|
||||
- auditorium
|
||||
- packages
|
||||
- build:
|
||||
<<: *build_preconditions
|
||||
- kms:
|
||||
<<: *build_preconditions
|
||||
- vault:
|
||||
<<: *build_preconditions
|
||||
- script:
|
||||
<<: *build_preconditions
|
||||
- auditorium:
|
||||
<<: *build_preconditions
|
||||
- packages:
|
||||
<<: *build_preconditions
|
||||
- shared:
|
||||
<<: *build_preconditions
|
||||
- accounts:
|
||||
<<: *build_preconditions
|
||||
- deploy_golang:
|
||||
<<: *deploy_preconditions
|
||||
- deploy_node:
|
||||
<<: *deploy_preconditions
|
||||
- deploy_python:
|
||||
<<: *deploy_preconditions
|
||||
- deploy_homepage:
|
||||
- deploy:
|
||||
<<: *deploy_preconditions
|
||||
|
19
.elasticbeanstalk/config.yml
Normal file
19
.elasticbeanstalk/config.yml
Normal file
@ -0,0 +1,19 @@
|
||||
branch-defaults:
|
||||
local-proxy:
|
||||
environment: production
|
||||
master:
|
||||
environment: production
|
||||
group_suffix: null
|
||||
global:
|
||||
application_name: offen
|
||||
branch: null
|
||||
default_ec2_keyname: aws-eb
|
||||
default_platform: Multi-container Docker 18.06.1-ce (Generic)
|
||||
default_region: eu-central-1
|
||||
include_git_submodules: true
|
||||
instance_profile: null
|
||||
platform_name: null
|
||||
platform_version: null
|
||||
repository: null
|
||||
sc: null
|
||||
workspace_type: Application
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -7,3 +7,4 @@ package-lock.json
|
||||
venv/
|
||||
|
||||
bootstrap-alpha.yml
|
||||
Dockerrun.aws.json
|
||||
|
31
Dockerrun.aws.json.production
Normal file
31
Dockerrun.aws.json.production
Normal file
@ -0,0 +1,31 @@
|
||||
{
|
||||
"AWSEBDockerrunVersion": 2,
|
||||
"volumes": [],
|
||||
"containerDefinitions": [
|
||||
{
|
||||
"name": "proxy",
|
||||
"image": "offen/proxy:stable",
|
||||
"essential": true,
|
||||
"memory": 128,
|
||||
"portMappings": [
|
||||
{
|
||||
"hostPort": 80,
|
||||
"containerPort": 80
|
||||
}
|
||||
],
|
||||
"dependsOn": [
|
||||
{
|
||||
"containerName": "server",
|
||||
"condition": "START"
|
||||
}
|
||||
],
|
||||
"links": ["server"]
|
||||
},
|
||||
{
|
||||
"name": "server",
|
||||
"image": "offen/server:stable",
|
||||
"essential": true,
|
||||
"memory": 256
|
||||
}
|
||||
]
|
||||
}
|
22
Makefile
22
Makefile
@ -1,28 +1,34 @@
|
||||
|
||||
help:
|
||||
@echo " setup"
|
||||
@echo " Build the containers and install dependencies."
|
||||
@echo " Build the development containers and install dependencies."
|
||||
@echo " bootstrap"
|
||||
@echo " Set up keys and seed databases."
|
||||
@echo " IMPORTANT: this wipes any existing data in your local database."
|
||||
@echo " build"
|
||||
@echo " Build the production containers."
|
||||
|
||||
setup:
|
||||
@docker-compose build
|
||||
@docker-compose run accounts pip install --user -r requirements.txt -r requirements-dev.txt
|
||||
@docker-compose run script npm install
|
||||
@docker-compose run vault npm install
|
||||
@docker-compose run auditorium npm install
|
||||
@docker-compose run server go mod download
|
||||
@docker-compose run kms go mod download
|
||||
@echo "Successfully built containers and installed dependencies."
|
||||
@echo "If this is your initial setup, you can run 'make bootstrap' next"
|
||||
@echo "to create the needed local keys and seed the database."
|
||||
|
||||
bootstrap:
|
||||
@echo "Bootstrapping KMS service ..."
|
||||
@docker-compose run kms make bootstrap
|
||||
@echo "Bootstrapping Server service ..."
|
||||
@docker-compose run server make bootstrap
|
||||
@echo "Bootstrapping Accounts service ..."
|
||||
@docker-compose run accounts make bootstrap
|
||||
|
||||
.PHONY: setup bootstrap
|
||||
DOCKER_IMAGE_TAG ?= latest
|
||||
|
||||
build:
|
||||
@docker build -t offen/server:${DOCKER_IMAGE_TAG} -f build/server/Dockerfile .
|
||||
@docker build --build-arg siteurl=${SITEURL} -t offen/proxy:${DOCKER_IMAGE_TAG} -f build/proxy/Dockerfile .
|
||||
|
||||
secret:
|
||||
@docker-compose run server make secret
|
||||
|
||||
.PHONY: setup bootstrap build secret
|
||||
|
16
README.md
16
README.md
@ -9,7 +9,7 @@ This repository contains all source code needed to build and run __offen__, both
|
||||
|
||||
---
|
||||
|
||||
Development of __offen__ has just started, so instructions are rare and things will stay highly volatile for quite some while. Also __do not use the software in its current state__ as it is still missing crucial pieces in protecting the data end to end.
|
||||
Development of __offen__ has just started, so instructions are rare and things will stay highly volatile for quite some while.
|
||||
|
||||
Guidelines for running and developing the Software will be added when it makes sense to do so.
|
||||
|
||||
@ -37,19 +37,7 @@ You can test your setup by starting the application:
|
||||
$ docker-compose up
|
||||
```
|
||||
|
||||
which should enable you to access <http://localhost:9955/> and use the `auditorium`
|
||||
|
||||
### Developing the homepage
|
||||
|
||||
In order to ease sharing of styles, the <https://www.offen.dev> site is also part of this repository. It runs in a separate development environment:
|
||||
|
||||
```
|
||||
$ cd homepage
|
||||
$ make setup
|
||||
$ docker-compose up
|
||||
```
|
||||
|
||||
A live reloading development server will run on <http://localhost:8000>.
|
||||
which should enable you to access <http://localhost:8080/auditorium/> and use the `auditorium`
|
||||
|
||||
### License
|
||||
|
||||
|
8
accounts/.gitignore
vendored
8
accounts/.gitignore
vendored
@ -1,8 +0,0 @@
|
||||
*.pem
|
||||
*.dot
|
||||
__pycache__
|
||||
*.log
|
||||
.vscode/
|
||||
.pytest_cache
|
||||
venv/
|
||||
*.pyc
|
@ -1,13 +0,0 @@
|
||||
test:
|
||||
@pytest --disable-pytest-warnings
|
||||
|
||||
test-ci: bootstrap
|
||||
@pytest --disable-pytest-warnings
|
||||
|
||||
fmt:
|
||||
@black .
|
||||
|
||||
bootstrap:
|
||||
@python -m scripts.bootstrap
|
||||
|
||||
.PHONY: test fmt bootstrap
|
@ -1,37 +0,0 @@
|
||||
# accounts
|
||||
|
||||
The `accounts` app is responsible for managing operator accounts and issuing authentication tokens that will identify requests made to the `server` and the `kms` service.
|
||||
|
||||
The application is built using the [Flask][flask-docs] framework.
|
||||
|
||||
[flask-docs]: http://flask.pocoo.org/
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
### Commands
|
||||
|
||||
#### Install dependencies
|
||||
|
||||
```
|
||||
pip install --user -r requirements.txt -r requirements-dev.txt
|
||||
```
|
||||
|
||||
#### Run the development server
|
||||
|
||||
```
|
||||
FLASK_APP=accounts FLASK_ENV=development flask run
|
||||
```
|
||||
|
||||
#### Run the tests
|
||||
|
||||
```
|
||||
make test
|
||||
```
|
||||
|
||||
#### Auto format code using `black`
|
||||
|
||||
```
|
||||
make fmt
|
||||
```
|
@ -1,25 +0,0 @@
|
||||
from os import environ
|
||||
|
||||
from flask import Flask
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_admin import Admin
|
||||
from werkzeug.utils import import_string
|
||||
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
cfg = import_string(environ.get("CONFIG_CLASS"))()
|
||||
app.config.from_object(cfg)
|
||||
|
||||
db = SQLAlchemy(app)
|
||||
|
||||
from accounts.models import Account, User
|
||||
from accounts.views import AccountView, UserView
|
||||
import accounts.api
|
||||
|
||||
admin = Admin(
|
||||
app, name="offen admin", template_mode="bootstrap3", base_template="index.html"
|
||||
)
|
||||
|
||||
admin.add_view(AccountView(Account, db.session))
|
||||
admin.add_view(UserView(User, db.session))
|
@ -1,134 +0,0 @@
|
||||
from datetime import datetime, timedelta
|
||||
from functools import wraps
|
||||
|
||||
from flask import jsonify, make_response, request
|
||||
from flask_cors import cross_origin
|
||||
from passlib.hash import bcrypt
|
||||
import jwt
|
||||
|
||||
from accounts import app
|
||||
from accounts.models import User
|
||||
|
||||
COOKIE_KEY = "auth"
|
||||
|
||||
|
||||
def json_error(handler):
|
||||
@wraps(handler)
|
||||
def wrapped_handler(*args, **kwargs):
|
||||
try:
|
||||
return handler(*args, **kwargs)
|
||||
except Exception as server_error:
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"error": "Internal server error: {}".format(str(server_error)),
|
||||
"status": 500,
|
||||
}
|
||||
),
|
||||
500,
|
||||
)
|
||||
|
||||
return wrapped_handler
|
||||
|
||||
|
||||
class UnauthorizedError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@app.route("/api/login", methods=["POST"])
|
||||
@cross_origin(origins=[app.config["CORS_ORIGIN"]], supports_credentials=True)
|
||||
@json_error
|
||||
def post_login():
|
||||
credentials = request.get_json(force=True)
|
||||
try:
|
||||
match = User.query.filter_by(email=credentials["username"]).first()
|
||||
if not match:
|
||||
raise UnauthorizedError("bad username")
|
||||
if not bcrypt.verify(credentials["password"], match.hashed_password):
|
||||
raise UnauthorizedError("bad password")
|
||||
except UnauthorizedError as unauthorized_error:
|
||||
resp = make_response(jsonify({"error": str(unauthorized_error), "status": 401}))
|
||||
resp.set_cookie(COOKIE_KEY, "", expires=0)
|
||||
resp.status_code = 401
|
||||
return resp
|
||||
|
||||
expiry = datetime.utcnow() + timedelta(hours=24)
|
||||
encoded = jwt.encode(
|
||||
{
|
||||
"exp": expiry,
|
||||
"priv": {
|
||||
"userId": match.user_id,
|
||||
"accounts": [a.account_id for a in match.accounts],
|
||||
},
|
||||
},
|
||||
app.config["JWT_PRIVATE_KEY"].encode(),
|
||||
algorithm="RS256",
|
||||
).decode()
|
||||
|
||||
resp = make_response(jsonify({"user": match.serialize()}))
|
||||
resp.set_cookie(
|
||||
COOKIE_KEY,
|
||||
encoded,
|
||||
httponly=True,
|
||||
expires=expiry,
|
||||
path="/",
|
||||
domain=app.config["COOKIE_DOMAIN"],
|
||||
samesite="strict",
|
||||
)
|
||||
return resp
|
||||
|
||||
|
||||
@app.route("/api/login", methods=["GET"])
|
||||
@cross_origin(origins=[app.config["CORS_ORIGIN"]], supports_credentials=True)
|
||||
@json_error
|
||||
def get_login():
|
||||
auth_cookie = request.cookies.get(COOKIE_KEY)
|
||||
if not auth_cookie:
|
||||
return jsonify({"error": "no auth cookie in request", "status": 401}), 401
|
||||
|
||||
public_keys = app.config["JWT_PUBLIC_KEYS"]
|
||||
|
||||
token = None
|
||||
token_err = None
|
||||
for public_key in public_keys:
|
||||
try:
|
||||
token = jwt.decode(auth_cookie, public_key)
|
||||
break
|
||||
except Exception as decode_err:
|
||||
token_err = decode_err
|
||||
|
||||
if not token:
|
||||
return jsonify({"error": str(token_err), "status": 401}), 401
|
||||
|
||||
try:
|
||||
match = User.query.get(token["priv"]["userId"])
|
||||
except KeyError as key_err:
|
||||
return (
|
||||
jsonify(
|
||||
{"error": "malformed JWT claims: {}".format(key_err), "status": 401}
|
||||
),
|
||||
401,
|
||||
)
|
||||
if match:
|
||||
return jsonify({"user": match.serialize()})
|
||||
return jsonify({"error": "unknown user id", "status": 401}), 401
|
||||
|
||||
|
||||
@app.route("/api/logout", methods=["POST"])
|
||||
@cross_origin(origins=[app.config["CORS_ORIGIN"]], supports_credentials=True)
|
||||
@json_error
|
||||
def post_logout():
|
||||
resp = make_response("")
|
||||
resp.set_cookie(COOKIE_KEY, "", expires=0)
|
||||
resp.status_code = 204
|
||||
return resp
|
||||
|
||||
|
||||
@app.route("/api/key", methods=["GET"])
|
||||
@json_error
|
||||
def key():
|
||||
"""
|
||||
This route is not supposed to be called by client-side applications, so
|
||||
no CORS configuration is added
|
||||
"""
|
||||
return jsonify({"keys": app.config["JWT_PUBLIC_KEYS"]})
|
@ -1,73 +0,0 @@
|
||||
import json
|
||||
from os import environ
|
||||
|
||||
|
||||
class BaseConfig(object):
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
FLASK_ADMIN_SWATCH = "flatly"
|
||||
|
||||
|
||||
class LocalConfig(BaseConfig):
|
||||
SECRET_KEY = environ.get("SESSION_SECRET")
|
||||
SQLALCHEMY_DATABASE_URI = environ.get("MYSQL_CONNECTION_STRING")
|
||||
CORS_ORIGIN = environ.get("CORS_ORIGIN", "*")
|
||||
COOKIE_DOMAIN = environ.get("COOKIE_DOMAIN")
|
||||
SERVER_HOST = environ.get("SERVER_HOST")
|
||||
def __init__(self):
|
||||
with open("private_key.pem") as f:
|
||||
private_key = f.read()
|
||||
with open("public_key.pem") as f:
|
||||
public_key = f.read()
|
||||
self.JWT_PRIVATE_KEY = private_key
|
||||
self.JWT_PUBLIC_KEYS = [public_key]
|
||||
|
||||
|
||||
class SecretsManagerConfig(BaseConfig):
|
||||
CORS_ORIGIN = environ.get("CORS_ORIGIN", "*")
|
||||
COOKIE_DOMAIN = environ.get("COOKIE_DOMAIN")
|
||||
SERVER_HOST = environ.get("SERVER_HOST")
|
||||
|
||||
def __init__(self):
|
||||
import boto3
|
||||
|
||||
session = boto3.session.Session()
|
||||
self.client = session.client(
|
||||
service_name="secretsmanager", region_name=environ.get("AWS_REGION")
|
||||
)
|
||||
|
||||
self.SECRET_KEY = self.get_secret("sessionSecret")
|
||||
self.SQLALCHEMY_DATABASE_URI = self.get_secret("mysqlConnectionString")
|
||||
|
||||
current_version = self.get_secret("jwtKeyPair")
|
||||
key_pair = json.loads(current_version)
|
||||
previous_version = self.get_secret("jwtKeyPair", previous=True)
|
||||
previous_key_pair = (
|
||||
json.loads(previous_version)
|
||||
if previous_version is not None
|
||||
else {"public": None}
|
||||
)
|
||||
|
||||
self.JWT_PRIVATE_KEY = key_pair["private"]
|
||||
self.JWT_PUBLIC_KEYS = [
|
||||
k for k in [key_pair["public"], previous_key_pair["public"]] if k
|
||||
]
|
||||
|
||||
def get_secret(self, secret_name, previous=False):
|
||||
import base64
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
try:
|
||||
ssm_response = self.client.get_secret_value(
|
||||
SecretId="{}/accounts/{}".format(environ.get("STAGE"), secret_name),
|
||||
VersionStage=("AWSPREVIOUS" if previous else "AWSCURRENT"),
|
||||
)
|
||||
except ClientError as e:
|
||||
if e.response["Error"]["Code"] == "ResourceNotFoundException" and previous:
|
||||
# A secret might not have a previous version yet. It is left
|
||||
# up to the caller to handle the None return in this case
|
||||
return None
|
||||
raise e
|
||||
|
||||
if "SecretString" in ssm_response:
|
||||
return ssm_response["SecretString"]
|
||||
return base64.b64decode(ssm_response["SecretBinary"])
|
@ -1,49 +0,0 @@
|
||||
from uuid import uuid4
|
||||
|
||||
from accounts import db
|
||||
|
||||
|
||||
def generate_key():
|
||||
return str(uuid4())
|
||||
|
||||
|
||||
class Account(db.Model):
|
||||
__tablename__ = "accounts"
|
||||
account_id = db.Column(db.String(36), primary_key=True, default=generate_key)
|
||||
name = db.Column(db.Text, nullable=False)
|
||||
users = db.relationship("AccountUserAssociation", back_populates="account", cascade="delete")
|
||||
|
||||
def __repr__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class User(db.Model):
|
||||
__tablename__ = "users"
|
||||
user_id = db.Column(db.String(36), primary_key=True, default=generate_key)
|
||||
email = db.Column(db.String(128), nullable=False, unique=True)
|
||||
hashed_password = db.Column(db.Text, nullable=False)
|
||||
accounts = db.relationship(
|
||||
"AccountUserAssociation", back_populates="user", lazy="joined"
|
||||
)
|
||||
|
||||
def serialize(self):
|
||||
associated_accounts = [a.account_id for a in self.accounts]
|
||||
records = [
|
||||
{"name": a.name, "accountId": a.account_id}
|
||||
for a in Account.query.filter(Account.account_id.in_(associated_accounts))
|
||||
]
|
||||
return {"userId": self.user_id, "email": self.email, "accounts": records}
|
||||
|
||||
|
||||
class AccountUserAssociation(db.Model):
|
||||
__tablename__ = "account_to_user"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
user_id = db.Column(db.String(36), db.ForeignKey("users.user_id"), nullable=False)
|
||||
account_id = db.Column(
|
||||
db.String(36), db.ForeignKey("accounts.account_id"), nullable=False
|
||||
)
|
||||
|
||||
user = db.relationship("User", back_populates="accounts")
|
||||
account = db.relationship("Account", back_populates="users")
|
@ -1,17 +0,0 @@
|
||||
{% extends 'admin/base.html' %}
|
||||
|
||||
{% block head_css %}
|
||||
<link href="https://stackpath.bootstrapcdn.com/bootswatch/3.3.5/flatly/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="{{ admin_static.url(filename='admin/css/bootstrap3/admin.css', v='1.1.1') }}" rel="stylesheet">
|
||||
<link href="{{ admin_static.url(filename='admin/css/bootstrap3/submenu.css') }}" rel="stylesheet">
|
||||
{% if admin_view.extra_css %}
|
||||
{% for css_url in admin_view.extra_css %}
|
||||
<link href="{{ css_url }}" rel="stylesheet">
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
<style>
|
||||
body {
|
||||
padding-top: 4px;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
@ -1,122 +0,0 @@
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import requests
|
||||
from flask_admin.contrib.sqla import ModelView
|
||||
from wtforms import PasswordField, StringField, Form
|
||||
from wtforms.validators import InputRequired, EqualTo
|
||||
from passlib.hash import bcrypt
|
||||
import jwt
|
||||
|
||||
from accounts import db, app
|
||||
from accounts.models import AccountUserAssociation
|
||||
|
||||
|
||||
class RemoteServerException(Exception):
|
||||
status = 0
|
||||
|
||||
def __str__(self):
|
||||
return "Status {}: {}".format(
|
||||
self.status, super(RemoteServerException, self).__str__()
|
||||
)
|
||||
|
||||
|
||||
def _call_remote_server(account_id, method):
|
||||
# expires in 30 seconds as this will mean the HTTP request would have
|
||||
# timed out anyways
|
||||
expiry = datetime.utcnow() + timedelta(seconds=30)
|
||||
encoded = jwt.encode(
|
||||
{"ok": True, "exp": expiry, "priv": {"rpc": "1"}},
|
||||
app.config["JWT_PRIVATE_KEY"].encode(),
|
||||
algorithm="RS256",
|
||||
).decode("utf-8")
|
||||
|
||||
do_request = None
|
||||
if method == "POST":
|
||||
do_request = requests.post
|
||||
elif method == "DELETE":
|
||||
do_request = requests.delete
|
||||
|
||||
if not do_request:
|
||||
raise Exception("Received unsupported method {}, cannot continue.".format(method))
|
||||
|
||||
r = do_request(
|
||||
"{}/accounts".format(app.config["SERVER_HOST"]),
|
||||
json={"accountId": account_id},
|
||||
headers={"X-RPC-Authentication": encoded},
|
||||
)
|
||||
|
||||
if r.status_code > 299:
|
||||
err = r.json()
|
||||
remote_err = RemoteServerException(err["error"])
|
||||
remote_err.status = err["status"]
|
||||
raise remote_err
|
||||
|
||||
|
||||
def create_remote_account(account_id):
|
||||
return _call_remote_server(account_id, "POST")
|
||||
|
||||
|
||||
def retire_remote_account(account_id):
|
||||
return _call_remote_server(account_id, "DELETE")
|
||||
|
||||
|
||||
class AccountForm(Form):
|
||||
name = StringField(
|
||||
"Account Name",
|
||||
validators=[InputRequired()],
|
||||
description="This is the account name visible to users",
|
||||
)
|
||||
|
||||
|
||||
class AccountView(ModelView):
|
||||
form = AccountForm
|
||||
column_display_all_relations = True
|
||||
column_list = ("name", "account_id")
|
||||
|
||||
def after_model_change(self, form, model, is_created):
|
||||
if is_created:
|
||||
try:
|
||||
create_remote_account(model.account_id)
|
||||
except RemoteServerException as server_error:
|
||||
db.session.delete(model)
|
||||
db.session.commit()
|
||||
raise server_error
|
||||
|
||||
def after_model_delete(self, model):
|
||||
retire_remote_account(model.account_id)
|
||||
|
||||
|
||||
class UserView(ModelView):
|
||||
inline_models = [(AccountUserAssociation, dict(form_columns=["id", "account"]))]
|
||||
column_auto_select_related = True
|
||||
column_display_all_relations = True
|
||||
column_list = ("email", "user_id")
|
||||
form_columns = ("email", "accounts")
|
||||
form_create_rules = ("email", "password", "confirm", "accounts")
|
||||
form_edit_rules = ("email", "password", "confirm", "accounts")
|
||||
|
||||
def on_model_change(self, form, model, is_created):
|
||||
if form.password.data:
|
||||
model.hashed_password = bcrypt.hash(form.password.data)
|
||||
|
||||
def get_create_form(self):
|
||||
form = super(UserView, self).get_create_form()
|
||||
form.password = PasswordField(
|
||||
"Password",
|
||||
validators=[
|
||||
InputRequired(),
|
||||
EqualTo("confirm", message="Passwords must match"),
|
||||
],
|
||||
)
|
||||
form.confirm = PasswordField("Repeat Password", validators=[InputRequired()])
|
||||
return form
|
||||
|
||||
def get_edit_form(self):
|
||||
form = super(UserView, self).get_edit_form()
|
||||
form.password = PasswordField(
|
||||
"Password",
|
||||
description="When left blank, the password will remain unchanged on update",
|
||||
validators=[EqualTo("confirm", message="Passwords must match")],
|
||||
)
|
||||
form.confirm = PasswordField("Repeat Password", validators=[])
|
||||
return form
|
@ -1,80 +0,0 @@
|
||||
import base64
|
||||
from os import environ
|
||||
|
||||
import boto3
|
||||
from botocore.exceptions import ClientError
|
||||
from passlib.hash import bcrypt
|
||||
|
||||
|
||||
session = boto3.session.Session()
|
||||
boto_client = session.client(
|
||||
service_name="secretsmanager", region_name=environ.get("AWS_REGION")
|
||||
)
|
||||
|
||||
|
||||
def get_secret(boto_client, secret_name):
|
||||
ssm_response = boto_client.get_secret_value(
|
||||
SecretId="{}/accounts/{}".format(environ.get("STAGE"), secret_name)
|
||||
)
|
||||
if "SecretString" in ssm_response:
|
||||
return ssm_response["SecretString"]
|
||||
return base64.b64decode(ssm_response["SecretBinary"])
|
||||
|
||||
|
||||
basic_auth_user = get_secret(boto_client, "basicAuthUser")
|
||||
hashed_basic_auth_password = get_secret(boto_client, "hashedBasicAuthPassword")
|
||||
|
||||
|
||||
def build_api_arn(method_arn):
|
||||
arn_chunks = method_arn.split(":")
|
||||
aws_region = arn_chunks[3]
|
||||
aws_account_id = arn_chunks[4]
|
||||
|
||||
gateway_arn_chunks = arn_chunks[5].split("/")
|
||||
rest_api_id = gateway_arn_chunks[0]
|
||||
stage = gateway_arn_chunks[1]
|
||||
|
||||
return "arn:aws:execute-api:{}:{}:{}/{}/*/*".format(
|
||||
aws_region, aws_account_id, rest_api_id, stage
|
||||
)
|
||||
|
||||
|
||||
def build_response(api_arn, allow):
|
||||
effect = "Deny"
|
||||
if allow:
|
||||
effect = "Allow"
|
||||
|
||||
return {
|
||||
"principalId": "offen",
|
||||
"policyDocument": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Action": ["execute-api:Invoke"],
|
||||
"Effect": effect,
|
||||
"Resource": [api_arn],
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def handler(event, context):
|
||||
api_arn = build_api_arn(event["methodArn"])
|
||||
|
||||
encoded_auth = event["authorizationToken"].lstrip("Basic ")
|
||||
auth_string = base64.standard_b64decode(encoded_auth).decode()
|
||||
if not auth_string:
|
||||
return build_response(api_arn, False)
|
||||
|
||||
credentials = auth_string.split(":")
|
||||
user = credentials[0]
|
||||
password = credentials[1]
|
||||
|
||||
if user != basic_auth_user:
|
||||
return build_response(api_arn, False)
|
||||
|
||||
if not bcrypt.verify(password, hashed_basic_auth_password):
|
||||
return build_response(api_arn, False)
|
||||
|
||||
return build_response(api_arn, True)
|
@ -1,21 +0,0 @@
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
|
||||
|
||||
def create_key_pair(**kwargs):
|
||||
key = rsa.generate_private_key(
|
||||
backend=default_backend(), public_exponent=65537, **kwargs
|
||||
)
|
||||
|
||||
public_key = key.public_key().public_bytes(
|
||||
serialization.Encoding.PEM, serialization.PublicFormat.PKCS1
|
||||
)
|
||||
|
||||
pem = key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
|
||||
return {"private": pem.decode(), "public": public_key.decode()}
|
@ -1,123 +0,0 @@
|
||||
import io
|
||||
import boto3
|
||||
import logging
|
||||
import json
|
||||
from os import environ
|
||||
|
||||
from lambdas.create_keys import create_key_pair
|
||||
|
||||
logger = logging.getLogger()
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
|
||||
def handler(event, context):
|
||||
arn = event["SecretId"]
|
||||
token = event["ClientRequestToken"]
|
||||
step = event["Step"]
|
||||
|
||||
session = boto3.session.Session()
|
||||
service_client = session.client(
|
||||
service_name="secretsmanager", region_name=environ.get("AWS_REGION")
|
||||
)
|
||||
|
||||
# Make sure the version is staged correctly
|
||||
metadata = service_client.describe_secret(SecretId=arn)
|
||||
if not metadata["RotationEnabled"]:
|
||||
logger.error("Secret %s is not enabled for rotation" % arn)
|
||||
raise ValueError("Secret %s is not enabled for rotation" % arn)
|
||||
versions = metadata["VersionIdsToStages"]
|
||||
if token not in versions:
|
||||
logger.error(
|
||||
"Secret version %s has no stage for rotation of secret %s.", token, arn
|
||||
)
|
||||
raise ValueError(
|
||||
"Secret version %s has no stage for rotation of secret %s." % (token, arn)
|
||||
)
|
||||
if "AWSCURRENT" in versions[token]:
|
||||
logger.info(
|
||||
"Secret version %s already set as AWSCURRENT for secret %s.", token, arn
|
||||
)
|
||||
return
|
||||
elif "AWSPENDING" not in versions[token]:
|
||||
logger.error(
|
||||
"Secret version %s not set as AWSPENDING for rotation of secret %s.",
|
||||
token,
|
||||
arn,
|
||||
)
|
||||
raise ValueError(
|
||||
"Secret version %s not set as AWSPENDING for rotation of secret %s."
|
||||
% (token, arn)
|
||||
)
|
||||
|
||||
if step == "createSecret":
|
||||
create_secret(service_client, arn, token)
|
||||
|
||||
elif step == "setSecret":
|
||||
set_secret(service_client, arn, token)
|
||||
|
||||
elif step == "testSecret":
|
||||
test_secret(service_client, arn, token)
|
||||
|
||||
elif step == "finishSecret":
|
||||
finish_secret(service_client, arn, token)
|
||||
|
||||
else:
|
||||
raise ValueError("Invalid step parameter")
|
||||
|
||||
|
||||
def create_secret(service_client, arn, token):
|
||||
service_client.get_secret_value(SecretId=arn, VersionStage="AWSCURRENT")
|
||||
try:
|
||||
service_client.get_secret_value(
|
||||
SecretId=arn, VersionId=token, VersionStage="AWSPENDING"
|
||||
)
|
||||
logger.info("createSecret: Successfully retrieved secret for %s." % arn)
|
||||
except service_client.exceptions.ResourceNotFoundException:
|
||||
secret = create_key_pair(key_size=2048)
|
||||
service_client.put_secret_value(
|
||||
SecretId=arn,
|
||||
ClientRequestToken=token,
|
||||
SecretString=json.dumps(secret).encode().decode("unicode_escape"),
|
||||
VersionStages=["AWSPENDING"],
|
||||
)
|
||||
logger.info(
|
||||
"createSecret: Successfully put secret for ARN %s and version %s."
|
||||
% (arn, token)
|
||||
)
|
||||
|
||||
|
||||
def set_secret(service_client, arn, token):
|
||||
pass
|
||||
|
||||
|
||||
def test_secret(service_client, arn, token):
|
||||
pass
|
||||
|
||||
|
||||
def finish_secret(service_client, arn, token):
|
||||
metadata = service_client.describe_secret(SecretId=arn)
|
||||
current_version = None
|
||||
for version in metadata["VersionIdsToStages"]:
|
||||
if "AWSCURRENT" in metadata["VersionIdsToStages"][version]:
|
||||
if version == token:
|
||||
# The correct version is already marked as current, return
|
||||
logger.info(
|
||||
"finishSecret: Version %s already marked as AWSCURRENT for %s",
|
||||
version,
|
||||
arn,
|
||||
)
|
||||
return
|
||||
current_version = version
|
||||
break
|
||||
|
||||
# Finalize by staging the secret version current
|
||||
service_client.update_secret_version_stage(
|
||||
SecretId=arn,
|
||||
VersionStage="AWSCURRENT",
|
||||
MoveToVersionId=token,
|
||||
RemoveFromVersionId=current_version,
|
||||
)
|
||||
logger.info(
|
||||
"finishSecret: Successfully set AWSCURRENT stage to version %s for secret %s."
|
||||
% (token, arn)
|
||||
)
|
@ -1,3 +0,0 @@
|
||||
pytest
|
||||
black
|
||||
pyyaml
|
@ -1,12 +0,0 @@
|
||||
Flask==1.0.2
|
||||
Flask-Admin==1.5.3
|
||||
Flask-Cors==3.0.8
|
||||
Flask-SQLAlchemy==2.4.0
|
||||
werkzeug==0.15.4
|
||||
pyjwt[crypto]==1.7.1
|
||||
passlib==1.7.1
|
||||
bcrypt==3.1.7
|
||||
PyMySQL==0.9.3
|
||||
mysqlclient==1.4.2.post1
|
||||
requests==2.22.0
|
||||
cryptography==2.7
|
@ -1,41 +0,0 @@
|
||||
import yaml
|
||||
from passlib.hash import bcrypt
|
||||
|
||||
from lambdas.create_keys import create_key_pair
|
||||
|
||||
if __name__ == "__main__":
|
||||
keypair = create_key_pair(key_size=2048)
|
||||
with open("public_key.pem", "w") as f:
|
||||
f.write(keypair["public"])
|
||||
|
||||
with open("private_key.pem", "w") as f:
|
||||
f.write(keypair["private"])
|
||||
|
||||
from accounts import db
|
||||
from accounts.models import Account, User, AccountUserAssociation
|
||||
|
||||
db.drop_all()
|
||||
db.create_all()
|
||||
|
||||
with open("./bootstrap.yml", "r") as stream:
|
||||
data = yaml.safe_load(stream)
|
||||
|
||||
for account in data["accounts"]:
|
||||
record = Account(
|
||||
name=account["name"],
|
||||
account_id=account["id"],
|
||||
)
|
||||
db.session.add(record)
|
||||
|
||||
for user in data["users"]:
|
||||
record = User(
|
||||
email=user["email"],
|
||||
hashed_password=bcrypt.hash(user["password"]),
|
||||
)
|
||||
for account_id in user["accounts"]:
|
||||
record.accounts.append(AccountUserAssociation(account_id=account_id))
|
||||
db.session.add(record)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
print("Successfully bootstrapped accounts database and created key pair")
|
@ -1,20 +0,0 @@
|
||||
import base64
|
||||
import argparse
|
||||
|
||||
from passlib.hash import bcrypt
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--password", type=str, help="The password to hash", required=True)
|
||||
parser.add_argument(
|
||||
"--plain",
|
||||
help="Do not encode the result as base64",
|
||||
default=False,
|
||||
action="store_true",
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = parser.parse_args()
|
||||
out = bcrypt.hash(args.password)
|
||||
if not args.plain:
|
||||
out = base64.standard_b64encode(out.encode()).decode()
|
||||
print(out)
|
@ -1,114 +0,0 @@
|
||||
service:
|
||||
name: accounts
|
||||
awsKmsKeyArn: ${ssm:/aws/reference/secretsmanager/${self:custom.stage}/all/kmsArn~true}
|
||||
|
||||
provider:
|
||||
name: aws
|
||||
endpointType: regional
|
||||
runtime: python3.6
|
||||
stage: alpha
|
||||
region: eu-central-1
|
||||
apiName: offen-${self:provider.stage}
|
||||
logs:
|
||||
restApi: true
|
||||
iamRoleStatements:
|
||||
- Effect: 'Allow'
|
||||
Action:
|
||||
- secretsmanager:GetSecretValue
|
||||
Resource: arn:aws:secretsmanager:eu-central-1:#{AWS::AccountId}:secret:${self:custom.stage}/*
|
||||
|
||||
package:
|
||||
individually: true
|
||||
exclude:
|
||||
- tests
|
||||
|
||||
plugins:
|
||||
- serverless-domain-manager
|
||||
- serverless-python-requirements
|
||||
- serverless-wsgi
|
||||
- serverless-pseudo-parameters
|
||||
|
||||
custom:
|
||||
stage: ${opt:stage, self:provider.stage}
|
||||
origin:
|
||||
production: https://vault.offen.dev
|
||||
staging: https://vault-staging.offen.dev
|
||||
alpha: https://vault-alpha.offen.dev
|
||||
serverHost:
|
||||
production: https://server.offen.dev
|
||||
staging: https://server-staging.offen.dev
|
||||
alpha: https://server-alpha.offen.dev
|
||||
domain:
|
||||
production: accounts.offen.dev
|
||||
staging: accounts-staging.offen.dev
|
||||
alpha: accounts-alpha.offen.dev
|
||||
cookieDomain:
|
||||
production: .offen.dev
|
||||
staging: .offen.dev
|
||||
alpha: .offen.dev
|
||||
customDomain:
|
||||
basePath: ''
|
||||
certificateName: '*.offen.dev'
|
||||
domainName: ${self:custom.domain.${self:custom.stage}}
|
||||
stage: ${self:custom.stage}
|
||||
endpointType: regional
|
||||
createRoute53Record: false
|
||||
wsgi:
|
||||
app: accounts.app
|
||||
packRequirements: false
|
||||
pythonRequirements:
|
||||
slim: true
|
||||
dockerizePip: non-linux
|
||||
fileName: requirements.txt
|
||||
|
||||
functions:
|
||||
authorizer:
|
||||
handler: lambdas.authorizer.handler
|
||||
environment:
|
||||
STAGE: ${self:custom.stage}
|
||||
rotateKeys:
|
||||
handler: lambdas.rotate_keys.handler
|
||||
environment:
|
||||
STAGE: ${self:custom.stage}
|
||||
app:
|
||||
handler: wsgi_handler.handler
|
||||
timeout: 30
|
||||
events:
|
||||
- http:
|
||||
path: /admin/
|
||||
method: any
|
||||
authorizer:
|
||||
name: authorizer
|
||||
resultTtlInSeconds: 0
|
||||
identitySource: method.request.header.Authorization
|
||||
- http:
|
||||
path: /admin/{proxy+}
|
||||
method: any
|
||||
authorizer:
|
||||
name: authorizer
|
||||
resultTtlInSeconds: 0
|
||||
identitySource: method.request.header.Authorization
|
||||
- http:
|
||||
path: '/'
|
||||
method: any
|
||||
- http:
|
||||
path: '/{proxy+}'
|
||||
method: any
|
||||
environment:
|
||||
CONFIG_CLASS: accounts.config.SecretsManagerConfig
|
||||
STAGE: ${self:custom.stage}
|
||||
CORS_ORIGIN: ${self:custom.origin.${self:custom.stage}}
|
||||
COOKIE_DOMAIN: ${self:custom.cookieDomain.${self:custom.stage}}
|
||||
SERVER_HOST: ${self:custom.serverHost.${self:custom.stage}}
|
||||
|
||||
resources:
|
||||
Resources:
|
||||
GatewayResponse:
|
||||
Type: 'AWS::ApiGateway::GatewayResponse'
|
||||
Properties:
|
||||
ResponseParameters:
|
||||
gatewayresponse.header.WWW-Authenticate: "'Basic'"
|
||||
ResponseType: UNAUTHORIZED
|
||||
RestApiId:
|
||||
Ref: 'ApiGatewayRestApi'
|
||||
StatusCode: '401'
|
@ -1,206 +0,0 @@
|
||||
import unittest
|
||||
import json
|
||||
import base64
|
||||
from time import time
|
||||
from datetime import datetime, timedelta
|
||||
from os import environ
|
||||
|
||||
import jwt
|
||||
|
||||
from accounts import app
|
||||
|
||||
|
||||
FOREIGN_PRIVATE_KEY = """
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCwAPFiTSLKlVvG
|
||||
N97TIyDWIxPp4Ji8hAmtlMn0gdGclC2DGKA2v7orXdNkngFon0PPe08acKI5NL9P
|
||||
nkVSrjWxrn8H7LeNQadwPxjYVmri4SLhBJUcAe+SoqrIZtrci+2y64mLPrl6wxBj
|
||||
ZKDl8o1Qm8iZSMgJ+wRG2FrItZUBWLZ79KSB2lQkO5OWorPX3T0SPxQXqq9hc4xN
|
||||
6I+qtfmv5jZTJOviMCehOs48ZlObgr/W+Kak4q/jrrqXvG3XQqVVTN/z95+2XuN4
|
||||
Btj7fv24PIRE/BddDAzC/yzISYb9QqLChaxx1fqY+aSA6ou2wh1PjUiyXNnAmP2i
|
||||
6UWwikILAgMBAAECggEBAJuYmc1/x+w00qeQKQubmKH27NnsVtsCF9Q/H7NrOTYl
|
||||
wX6OPMVqBlnkXsgq76/gbQB2UN5dCO1t9lua3kpT/OASFfeZjEPy8OXIwlwvOdtN
|
||||
kZpAhNn31CZcbIMyevZTNlbg5/4T+8HNxSU5hw0Cu2+x6UuqDj7UjVlcWBXsgchn
|
||||
f8kguLHr6Q7rndC10Vv5a4Rz9fzuS2K4jEnhlJjgD22XB2SCH5kLrAikH10AW761
|
||||
5g7HSiMxKSUyXc51PX3n/FkxjzT0Vm1ENeZou263VEQhke49IWLIcbLD7ShOyNaI
|
||||
TuYPAyRY4o70/d/YTydRCEp/H8stB6UaVK9hlzzfoMECgYEA1e9UgW4vBueSoZv3
|
||||
llc7dXlAnk6nJeCaujjPBAd0Sc3XcpMik1kb8oDgI4fwNxTYqlHu3Wp7ZLR14C4G
|
||||
rlry+4rRUdxnWNcKtyOtA6km0b33V3ja4GsLViENBSQZDUe7EljER2VSRynMTog0
|
||||
lfmUr+ORzWDpanEO+Ke25zhU2DsCgYEA0pxM2UjmmAepSWBAcXABjIFE09MxXVTS
|
||||
NwRhdYjHJsKmGnPD8DEDJbRSHNAEN2mTD2kJW5pFThKVWtQ8WpjSXuRSkS7HzXrU
|
||||
zMNZnzTDdTZl6nnui3RJtIYntSXR7ommC6ldY7nlnHnzkIEcDLwN6E/JNOB5gtTE
|
||||
L4ztUpKncHECgYBO3qHX6agasorjW52mZlh8UYxaEIMcurYwSzs+sATWJLX1/npz
|
||||
uhlMiOiZEMelduD9waD/Lf95u/HtCOrbopoL1DyhIlFTdkv0AooJXHX8Qz2JmPuQ
|
||||
WsZeJWcoawt1UumLtP//lkIEDEvO8/X3CIEhaxNYlQ7Yd//d+e67RZA5+wKBgD6f
|
||||
qR4m1iI4jPa7fw377wn3Wh7eOlx1Hziqvcv0CruUv004RPfDqxrn/k6A7/AGHWtE
|
||||
oTqyqY7oaa6jUvrhXBRJMd/nmBOaRXJJV/nF96R/s1hAP1UKE+xww5fSkhSqq0vm
|
||||
ZVWE7ihT/r9mFJAYzs3YA40MfjUPzPISpnKaFt2RAoGBANCtswMqztcuPDF5rL3d
|
||||
rqB6jwFrXKvwrx4HxOmF/MgGPyp6MWLBEnpZDvLJo9uSafq6Q6IwOQMWWF5GO7JO
|
||||
4EG9ldVugR/CtmL3+XTHE4MGPXmqHg/q/o7rItc7g11iXJTndcUZtWGwkHwl4zBF
|
||||
15NFZ2gU4rKnQ3sVAOzMoEw5
|
||||
-----END PRIVATE KEY-----
|
||||
"""
|
||||
|
||||
def _pad_b64_string(s):
|
||||
while len(s) % 4 is not 0:
|
||||
s = s + "="
|
||||
return s
|
||||
|
||||
|
||||
class TestKey(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.app = app.test_client()
|
||||
|
||||
def test_get_key(self):
|
||||
rv = self.app.get("/api/key")
|
||||
assert rv.status.startswith("200")
|
||||
data = json.loads(rv.data)
|
||||
assert data["keys"]
|
||||
|
||||
|
||||
class TestJWT(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.app = app.test_client()
|
||||
|
||||
def _assert_cookie_present(self, name):
|
||||
for cookie in self.app.cookie_jar:
|
||||
if cookie.name == name:
|
||||
return cookie.value
|
||||
raise AssertionError("Cookie named {} not found".format(name))
|
||||
|
||||
def _assert_cookie_not_present(self, name):
|
||||
for cookie in self.app.cookie_jar:
|
||||
assert cookie.name != name
|
||||
|
||||
def test_jwt_flow(self):
|
||||
"""
|
||||
First, try login attempts that are supposed to fail:
|
||||
1. checking login status without any prior interaction
|
||||
2. try logging in with an unknown user
|
||||
3. try logging in with a known user and bad password
|
||||
"""
|
||||
rv = self.app.get("/api/login")
|
||||
assert rv.status.startswith("401")
|
||||
self._assert_cookie_not_present("auth")
|
||||
|
||||
rv = self.app.post(
|
||||
"/api/login",
|
||||
data=json.dumps(
|
||||
{"username": "does@not.exist", "password": "somethingsomething"}
|
||||
),
|
||||
)
|
||||
assert rv.status.startswith("401")
|
||||
self._assert_cookie_not_present("auth")
|
||||
|
||||
rv = self.app.post(
|
||||
"/api/login",
|
||||
data=json.dumps({"username": "develop@offen.dev", "password": "developp"}),
|
||||
)
|
||||
assert rv.status.startswith("401")
|
||||
self._assert_cookie_not_present("auth")
|
||||
|
||||
"""
|
||||
Next, perform a successful login
|
||||
"""
|
||||
rv = self.app.post(
|
||||
"/api/login",
|
||||
data=json.dumps({"username": "develop@offen.dev", "password": "develop"}),
|
||||
)
|
||||
assert rv.status.startswith("200")
|
||||
|
||||
"""
|
||||
The response should contain information about the
|
||||
user and full information (i.e. a name) about the associated accounts
|
||||
"""
|
||||
data = json.loads(rv.data)
|
||||
assert data["user"]["userId"] is not None
|
||||
data["user"]["accounts"].sort(key=lambda a: a["name"])
|
||||
self.assertListEqual(
|
||||
data["user"]["accounts"],
|
||||
[
|
||||
{"name": "One", "accountId": "9b63c4d8-65c0-438c-9d30-cc4b01173393"},
|
||||
{"name": "Two", "accountId": "78403940-ae4f-4aff-a395-1e90f145cf62"},
|
||||
],
|
||||
)
|
||||
|
||||
"""
|
||||
The claims part of the JWT is expected to contain a valid expiry,
|
||||
information about the user and the associated account ids.
|
||||
"""
|
||||
jwt = self._assert_cookie_present("auth")
|
||||
# PyJWT strips the padding from the base64 encoded parts which Python
|
||||
# cannot decode properly, so we need to add the padding ourselves
|
||||
claims_part = _pad_b64_string(jwt.split(".")[1])
|
||||
claims = json.loads(base64.b64decode(claims_part))
|
||||
assert claims.get("exp") > time()
|
||||
|
||||
priv = claims.get("priv")
|
||||
assert priv is not None
|
||||
|
||||
assert priv.get("userId") is not None
|
||||
self.assertListEqual(
|
||||
priv["accounts"],
|
||||
[
|
||||
"9b63c4d8-65c0-438c-9d30-cc4b01173393",
|
||||
"78403940-ae4f-4aff-a395-1e90f145cf62",
|
||||
],
|
||||
)
|
||||
|
||||
"""
|
||||
Checking the login status when re-using the cookie should yield
|
||||
a successful response
|
||||
"""
|
||||
rv = self.app.get("/api/login")
|
||||
assert rv.status.startswith("200")
|
||||
jwt2 = self._assert_cookie_present("auth")
|
||||
assert jwt2 == jwt
|
||||
|
||||
"""
|
||||
Performing a bad login attempt when sending a valid auth cookie
|
||||
is expected to destroy the cookie and leave the user logged out again
|
||||
"""
|
||||
rv = self.app.post(
|
||||
"/api/login",
|
||||
data=json.dumps(
|
||||
{"username": "evil@session.takeover", "password": "develop"}
|
||||
),
|
||||
)
|
||||
assert rv.status.startswith("401")
|
||||
self._assert_cookie_not_present("auth")
|
||||
|
||||
"""
|
||||
Explicitly logging out leaves the user without cookies
|
||||
"""
|
||||
rv = self.app.post(
|
||||
"/api/login",
|
||||
data=json.dumps({"username": "develop@offen.dev", "password": "develop"}),
|
||||
)
|
||||
assert rv.status.startswith("200")
|
||||
|
||||
rv = self.app.post("/api/logout")
|
||||
assert rv.status.startswith("204")
|
||||
self._assert_cookie_not_present("auth")
|
||||
|
||||
def test_forged_token(self):
|
||||
"""
|
||||
The application needs to verify that tokens that would be theoretically
|
||||
valid are not signed using an unknown key.
|
||||
"""
|
||||
forged_token = jwt.encode(
|
||||
{
|
||||
"exp": datetime.utcnow() + timedelta(hours=24),
|
||||
"priv": {
|
||||
"userId": "8bc8db1b-f32d-4376-a1cf-724bf6a597b8",
|
||||
"accounts": [
|
||||
"9b63c4d8-65c0-438c-9d30-cc4b01173393",
|
||||
"78403940-ae4f-4aff-a395-1e90f145cf62",
|
||||
],
|
||||
},
|
||||
},
|
||||
FOREIGN_PRIVATE_KEY,
|
||||
algorithm="RS256",
|
||||
).decode()
|
||||
|
||||
self.app.set_cookie("localhost", "auth", forged_token)
|
||||
rv = self.app.get("/api/login")
|
||||
assert rv.status.startswith("401")
|
70
build/proxy/Dockerfile
Normal file
70
build/proxy/Dockerfile
Normal file
@ -0,0 +1,70 @@
|
||||
FROM node:10 as auditorium
|
||||
|
||||
COPY ./auditorium/package.json /code/deps/package.json
|
||||
COPY ./packages /code/packages
|
||||
WORKDIR /code/deps
|
||||
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD true
|
||||
RUN npm install
|
||||
COPY ./auditorium /code/auditorium
|
||||
COPY ./styles /code/styles
|
||||
COPY ./banner.txt /code/banner.txt
|
||||
WORKDIR /code/auditorium
|
||||
RUN cp -a /code/deps/node_modules /code/auditorium/
|
||||
ENV NODE_ENV production
|
||||
RUN npm run build
|
||||
|
||||
FROM node:10 as script
|
||||
|
||||
COPY ./script/package.json /code/deps/package.json
|
||||
COPY ./packages /code/packages
|
||||
WORKDIR /code/deps
|
||||
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD true
|
||||
RUN npm install
|
||||
COPY ./script /code/script
|
||||
COPY ./banner.txt /code/banner.txt
|
||||
WORKDIR /code/script
|
||||
RUN cp -a /code/deps/node_modules /code/script/
|
||||
ENV NODE_ENV production
|
||||
RUN npm run build
|
||||
|
||||
FROM node:10 as vault
|
||||
|
||||
COPY ./vault/package.json /code/deps/package.json
|
||||
COPY ./packages /code/packages
|
||||
WORKDIR /code/deps
|
||||
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD true
|
||||
RUN npm install
|
||||
COPY ./vault /code/vault
|
||||
COPY ./banner.txt /code/banner.txt
|
||||
WORKDIR /code/vault
|
||||
RUN cp -a /code/deps/node_modules /code/vault/
|
||||
ENV NODE_ENV production
|
||||
RUN npm run build
|
||||
|
||||
FROM nikolaik/python-nodejs:python3.6-nodejs10 as homepage
|
||||
|
||||
ARG siteurl
|
||||
ENV SITEURL=$siteurl
|
||||
|
||||
COPY ./homepage /code/homepage
|
||||
COPY ./styles /code/styles
|
||||
|
||||
RUN npm install svgo -g
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y libjpeg-progs optipng \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /code/homepage
|
||||
ENV PATH /root/.local/bin:$PATH
|
||||
RUN pip install --user -r requirements.txt
|
||||
RUN make publish
|
||||
|
||||
FROM nginx:1.17-alpine
|
||||
|
||||
COPY --from=homepage /code/homepage/output /www/data
|
||||
COPY --from=script /code/script/dist /www/data
|
||||
COPY --from=auditorium /code/auditorium/dist /www/data/auditorium
|
||||
COPY --from=vault /code/vault/dist /www/data/vault
|
||||
COPY ./build/proxy/nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
EXPOSE 80
|
32
build/proxy/nginx.conf
Normal file
32
build/proxy/nginx.conf
Normal file
@ -0,0 +1,32 @@
|
||||
events {}
|
||||
|
||||
http {
|
||||
gzip on;
|
||||
gzip_comp_level 2;
|
||||
gzip_min_length 1000;
|
||||
gzip_proxied expired no-cache no-store private auth;
|
||||
|
||||
include mime.types;
|
||||
upstream server {
|
||||
server server:3000;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
autoindex on;
|
||||
root /www/data;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://server;
|
||||
proxy_redirect off;
|
||||
rewrite ^/api(.*)$ $1 break;
|
||||
proxy_hide_header Content-Type;
|
||||
add_header Content-Type "application/json";
|
||||
}
|
||||
|
||||
location /auditorium/ {
|
||||
try_files $uri $uri/ /auditorium/index.html;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,37 +1,19 @@
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
kms:
|
||||
build:
|
||||
context: '.'
|
||||
dockerfile: Dockerfile.golang
|
||||
working_dir: /offen/kms
|
||||
proxy:
|
||||
image: nginx:1.17-alpine
|
||||
volumes:
|
||||
- .:/offen
|
||||
- kmsdeps:/go/pkg/mod
|
||||
environment:
|
||||
KEY_FILE: key.txt
|
||||
PORT: 8081
|
||||
CORS_ORIGIN: http://localhost:9977
|
||||
JWT_PUBLIC_KEY: http://accounts:5000/api/key
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf
|
||||
- ./styles:/code/styles
|
||||
ports:
|
||||
- 8081:8081
|
||||
command: refresh run
|
||||
links:
|
||||
- accounts
|
||||
|
||||
server_database:
|
||||
image: postgres:11.2
|
||||
environment:
|
||||
POSTGRES_PASSWORD: develop
|
||||
|
||||
accounts_database:
|
||||
image: mysql:5.7
|
||||
ports:
|
||||
- "3306:3306"
|
||||
environment:
|
||||
MYSQL_DATABASE: mysql
|
||||
MYSQL_ROOT_PASSWORD: develop
|
||||
- 8080:80
|
||||
depends_on:
|
||||
- homepage
|
||||
- server
|
||||
- auditorium
|
||||
- vault
|
||||
- script
|
||||
|
||||
server:
|
||||
build:
|
||||
@ -43,22 +25,20 @@ services:
|
||||
- ./bootstrap.yml:/offen/server/bootstrap.yml
|
||||
- serverdeps:/go/pkg/mod
|
||||
environment:
|
||||
CORS_ORIGIN: http://localhost:9977
|
||||
OPTOUT_COOKIE_DOMAIN: localhost
|
||||
POSTGRES_CONNECTION_STRING: postgres://postgres:develop@server_database:5432/postgres?sslmode=disable
|
||||
KMS_ENCRYPTION_ENDPOINT: http://kms:8081/encrypt
|
||||
PORT: 8080
|
||||
JWT_PUBLIC_KEY: http://accounts:5000/api/key
|
||||
DEVELOPMENT: '1'
|
||||
COOKIE_EXCHANGE_SECRET: Wsttdo4Z3mXV5sTc
|
||||
COOKIE_EXCHANGE_SECRET: 3P+w6QetKO3Kn8h1YyRlCw==
|
||||
EVENT_RETENTION_PERIOD: 4464h
|
||||
ports:
|
||||
- 8080:8080
|
||||
ACCOUNT_USER_EMAIL_SALT: JuhbRA4lCdo8rt5qVdLpk3==
|
||||
command: refresh run
|
||||
links:
|
||||
- server_database
|
||||
depends_on:
|
||||
- kms
|
||||
|
||||
server_database:
|
||||
image: postgres:11.2
|
||||
environment:
|
||||
POSTGRES_PASSWORD: develop
|
||||
|
||||
vault:
|
||||
build:
|
||||
@ -69,15 +49,6 @@ services:
|
||||
- .:/offen
|
||||
- vaultdeps:/offen/vault/node_modules
|
||||
command: npm start -- --port 9977
|
||||
ports:
|
||||
- 9977:9977
|
||||
environment:
|
||||
- SERVER_HOST=http://localhost:8080
|
||||
- KMS_HOST=http://localhost:8081
|
||||
- SCRIPT_HOST=http://localhost:9977
|
||||
- AUDITORIUM_HOST=http://localhost:9955
|
||||
- ACCOUNTS_HOST=http://localhost:5000
|
||||
- HOMEPAGE_HOST=http://localhost:8000
|
||||
|
||||
script:
|
||||
build:
|
||||
@ -88,10 +59,6 @@ services:
|
||||
- .:/offen
|
||||
- scriptdeps:/offen/script/node_modules
|
||||
command: npm start -- --port 9966
|
||||
ports:
|
||||
- 9966:9966
|
||||
environment:
|
||||
- VAULT_HOST=http://localhost:9977
|
||||
|
||||
auditorium:
|
||||
build:
|
||||
@ -102,38 +69,24 @@ services:
|
||||
- .:/offen
|
||||
- auditoriumdeps:/offen/auditorium/node_modules
|
||||
command: npm start -- --port 9955
|
||||
ports:
|
||||
- 9955:9955
|
||||
environment:
|
||||
- VAULT_HOST=http://localhost:9977
|
||||
|
||||
accounts:
|
||||
homepage:
|
||||
build:
|
||||
context: '.'
|
||||
dockerfile: Dockerfile.python
|
||||
working_dir: /offen/accounts
|
||||
dockerfile: ./Dockerfile.python
|
||||
working_dir: /offen/homepage
|
||||
volumes:
|
||||
- .:/offen
|
||||
- ./bootstrap.yml:/offen/accounts/bootstrap.yml
|
||||
- accountdeps:/root/.local
|
||||
command: flask run --host 0.0.0.0
|
||||
- homepagedeps:/root/.local
|
||||
command: make devserver
|
||||
ports:
|
||||
- 5000:5000
|
||||
links:
|
||||
- accounts_database
|
||||
- 8000:8000
|
||||
environment:
|
||||
CONFIG_CLASS: accounts.config.LocalConfig
|
||||
FLASK_APP: accounts:app
|
||||
FLASK_ENV: development
|
||||
MYSQL_CONNECTION_STRING: mysql+pymysql://root:develop@accounts_database:3306/mysql
|
||||
CORS_ORIGIN: http://localhost:9977
|
||||
SERVER_HOST: http://server:8080
|
||||
SESSION_SECRET: vndJRFJTiyjfgtTF
|
||||
DEBUG: 1
|
||||
|
||||
volumes:
|
||||
kmsdeps:
|
||||
serverdeps:
|
||||
scriptdeps:
|
||||
auditoriumdeps:
|
||||
vaultdeps:
|
||||
accountdeps:
|
||||
homepagedeps:
|
||||
|
@ -1 +0,0 @@
|
||||
www.offen.dev
|
@ -12,7 +12,7 @@ Usage metrics come with explanations about their meaning, relevance, usage and p
|
||||
|
||||
__offen__ treats both users and operators as parties of equal importance. Users can expect full transparency and are encouraged to make *autonomous and informed decisions regarding the use of their data*. Operators are enabled to gain insights while respecting their users' privacy and data.
|
||||
|
||||
__offen__ is currently in the early stages of development and is applying for funds to sustain its development. An early alpha version is running on this site: you can *visit the [auditorium](https://auditorium-alpha.offen.dev){: target="_blank"}* to access your data.
|
||||
__offen__ is currently in the early stages of development and is applying for funds to sustain its development. An early alpha version is running on this site: you can *visit the [auditorium](/auditorium/)* to access your data.
|
||||
|
||||
<div class="btn-wrapper">
|
||||
<a class="btn btn-color-yellow" href="/deep-dive/">Deep dive</a>
|
||||
|
@ -1,19 +0,0 @@
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
homepage:
|
||||
build:
|
||||
context: '.'
|
||||
dockerfile: ./../Dockerfile.python
|
||||
working_dir: /offen/homepage
|
||||
volumes:
|
||||
- ..:/offen
|
||||
- homepagedeps:/root/.local
|
||||
command: make devserver
|
||||
ports:
|
||||
- 8000:8000
|
||||
environment:
|
||||
DEBUG: '1'
|
||||
|
||||
volumes:
|
||||
homepagedeps:
|
@ -11,8 +11,8 @@ sys.path.append(os.curdir)
|
||||
from pelicanconf import *
|
||||
|
||||
# If your site is available via HTTPS, make sure SITEURL begins with https://
|
||||
SITEURL = 'https://www.offen.dev'
|
||||
RELATIVE_URLS = False
|
||||
SITEURL = os.environ.get('SITEURL', 'https://www.offen.dev')
|
||||
# RELATIVE_URLS = True
|
||||
|
||||
FEED_ALL_ATOM = 'feeds/all.atom.xml'
|
||||
CATEGORY_FEED_ATOM = 'feeds/{slug}.atom.xml'
|
||||
|
@ -1,8 +1,8 @@
|
||||
;(function ($) {
|
||||
$(document).ready(function () {
|
||||
$(window).scroll(function () {
|
||||
var scrollProgress = parseInt($(window).scrollTop(), 10)
|
||||
if ($(window).width() > 960) {
|
||||
var scrollProgress = parseInt($(window).scrollTop(), 10)
|
||||
$('body.index .brand').css('opacity', Math.min(scrollProgress / 100, 1))
|
||||
}
|
||||
})
|
||||
|
@ -1,9 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="content">
|
||||
<div class="container-full">
|
||||
{{ page.content }}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
@ -20,10 +20,10 @@
|
||||
<link rel="canonical" href="{{ SITEURL }}/{{ page.save_as }}">
|
||||
<link rel="shortcut icon" type="image/x-icon" href="/theme/images/favicon.ico">
|
||||
{% assets filters="cssmin", output="css/style.min.css", "css/normalize.css", "css/fonts.css", "css/style.css" %}
|
||||
<link rel="stylesheet" href="{{ SITEURL }}/{{ ASSET_URL }}">
|
||||
<link rel="stylesheet" href="/{{ ASSET_URL }}">
|
||||
{% endassets %}
|
||||
{% if OFFEN_ACCOUNT_ID %}
|
||||
<script async src="https://script-alpha.offen.dev/script.js" data-account-id="{{ OFFEN_ACCOUNT_ID }}"></script>
|
||||
<script async src="/script.js" data-account-id="{{ OFFEN_ACCOUNT_ID }}"></script>
|
||||
{% endif %}
|
||||
</head>
|
||||
<body class="{{page.template}}">
|
||||
@ -136,8 +136,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% block scripts %}
|
||||
{% assets filters="rjsmin", output="scripts/packed.js", "scripts/jquery-3.4.1.min.js", "scripts/menu.js", "scripts/fade.js" %}
|
||||
<script src="{{ SITEURL }}/{{ ASSET_URL }}"></script>
|
||||
<script src="/{{ ASSET_URL }}"></script>
|
||||
{% endassets %}
|
||||
{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
25
package.json
25
package.json
@ -1,25 +0,0 @@
|
||||
{
|
||||
"name": "offen",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/offen/offen.git"
|
||||
},
|
||||
"author": "offen",
|
||||
"license": "MIT",
|
||||
"bugs": {
|
||||
"url": "https://github.com/offen/offen/issues"
|
||||
},
|
||||
"homepage": "https://github.com/offen/offen#readme",
|
||||
"dependencies": {
|
||||
"serverless": "^1.45.0",
|
||||
"serverless-apigw-binary": "^0.4.4",
|
||||
"serverless-domain-manager": "^2.6.13",
|
||||
"serverless-finch": "^2.4.2",
|
||||
"serverless-pseudo-parameters": "^2.4.0",
|
||||
"serverless-python-requirements": "^4.3.0",
|
||||
"serverless-wsgi": "^1.7.2"
|
||||
},
|
||||
"devDependencies": {}
|
||||
}
|
@ -1,2 +0,0 @@
|
||||
test:
|
||||
@go test -race -cover ./...
|
@ -1,15 +0,0 @@
|
||||
# shared
|
||||
|
||||
`shared` contains Go packages shared across applications. Consumers need to symlink the folder into their app directory.
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
### Commands
|
||||
|
||||
#### Run the tests
|
||||
|
||||
```
|
||||
make test
|
||||
```
|
@ -1,24 +0,0 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type errorResponse struct {
|
||||
Error string `json:"error"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
|
||||
func jsonError(err error, status int) []byte {
|
||||
r := errorResponse{err.Error(), status}
|
||||
b, _ := json.Marshal(r)
|
||||
return b
|
||||
}
|
||||
|
||||
// RespondWithJSONError writes the given error to the given response writer
|
||||
// while wrapping it into a JSON payload.
|
||||
func RespondWithJSONError(w http.ResponseWriter, err error, status int) {
|
||||
w.WriteHeader(status)
|
||||
w.Write(jsonError(err, status))
|
||||
}
|
@ -1,19 +0,0 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRespondWithJSONError(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
RespondWithJSONError(w, errors.New("does not work"), http.StatusInternalServerError)
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("Unexpected status code %d", w.Code)
|
||||
}
|
||||
if w.Body.String() != `{"error":"does not work","status":500}` {
|
||||
t.Errorf("Unexpected response body %s", w.Body.String())
|
||||
}
|
||||
}
|
@ -1,174 +0,0 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/lestrrat-go/jwx/jwa"
|
||||
"github.com/lestrrat-go/jwx/jwt"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
// ClaimsContextKey will be used to attach a JWT claim to a request context
|
||||
const ClaimsContextKey contextKey = "claims"
|
||||
|
||||
// JWTProtect uses the public key located at the given URL to check if the
|
||||
// cookie value is signed properly. In case yes, the JWT claims will be added
|
||||
// to the request context
|
||||
func JWTProtect(keyURL, cookieName, headerName string, authorizer func(*http.Request, map[string]interface{}) error, cache Cache) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var jwtValue string
|
||||
if authCookie, err := r.Cookie(cookieName); err == nil {
|
||||
jwtValue = authCookie.Value
|
||||
} else {
|
||||
if header := r.Header.Get(headerName); header != "" {
|
||||
jwtValue = header
|
||||
}
|
||||
}
|
||||
if jwtValue == "" {
|
||||
RespondWithJSONError(w, errors.New("jwt: could not infer JWT value from cookie or header"), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
var keys [][]byte
|
||||
if cache != nil {
|
||||
lookup, lookupErr := cache.Get()
|
||||
if lookupErr == nil {
|
||||
keys = lookup
|
||||
}
|
||||
}
|
||||
if keys == nil {
|
||||
var keysErr error
|
||||
keys, keysErr = fetchKeys(keyURL)
|
||||
if keysErr != nil {
|
||||
RespondWithJSONError(w, fmt.Errorf("jwt: error fetching keys: %v", keysErr), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if cache != nil {
|
||||
cache.Set(keys)
|
||||
}
|
||||
}
|
||||
|
||||
var token *jwt.Token
|
||||
var tokenErr error
|
||||
// the response can contain multiple keys to try as some of them
|
||||
// might have been retired with signed tokens still in use until
|
||||
// their expiry
|
||||
for _, key := range keys {
|
||||
token, tokenErr = tryParse(key, jwtValue)
|
||||
if tokenErr == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if tokenErr != nil {
|
||||
RespondWithJSONError(w, fmt.Errorf("jwt: error verifying token signature: %v", tokenErr), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if err := token.Verify(jwt.WithAcceptableSkew(0)); err != nil {
|
||||
RespondWithJSONError(w, fmt.Errorf("jwt: error verifying token claims: %v", err), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
privKey, _ := token.Get("priv")
|
||||
claims, _ := privKey.(map[string]interface{})
|
||||
if err := authorizer(r, claims); err != nil {
|
||||
RespondWithJSONError(w, fmt.Errorf("jwt: token claims do not allow the requested operation: %v", err), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
r = r.WithContext(context.WithValue(r.Context(), ClaimsContextKey, claims))
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func tryParse(key []byte, tokenValue string) (*jwt.Token, error) {
|
||||
keyBytes, _ := pem.Decode([]byte(key))
|
||||
if keyBytes == nil {
|
||||
return nil, errors.New("jwt: no PEM block found in given key")
|
||||
}
|
||||
|
||||
pubKey, parseErr := x509.ParsePKCS1PublicKey(keyBytes.Bytes)
|
||||
if parseErr != nil {
|
||||
return nil, fmt.Errorf("jwt: error parsing key: %v", parseErr)
|
||||
}
|
||||
|
||||
token, jwtErr := jwt.ParseVerify(strings.NewReader(tokenValue), jwa.RS256, pubKey)
|
||||
if jwtErr != nil {
|
||||
return nil, fmt.Errorf("jwt: error parsing token: %v", jwtErr)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
type keyResponse struct {
|
||||
Keys []string `json:"keys"`
|
||||
}
|
||||
|
||||
func fetchKeys(keyURL string) ([][]byte, error) {
|
||||
fetchRes, fetchErr := http.Get(keyURL)
|
||||
if fetchErr != nil {
|
||||
return nil, fetchErr
|
||||
}
|
||||
defer fetchRes.Body.Close()
|
||||
payload := keyResponse{}
|
||||
if err := json.NewDecoder(fetchRes.Body).Decode(&payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
asBytes := [][]byte{}
|
||||
for _, key := range payload.Keys {
|
||||
asBytes = append(asBytes, []byte(key))
|
||||
}
|
||||
return asBytes, nil
|
||||
}
|
||||
|
||||
// Cache can be implemented by consumers in order to define how requests
|
||||
// for public keys are being cached. For most use cases, the default cache
|
||||
// supplied by this package will suffice.
|
||||
type Cache interface {
|
||||
Get() ([][]byte, error)
|
||||
Set([][]byte)
|
||||
}
|
||||
|
||||
type defaultCache struct {
|
||||
value *[][]byte
|
||||
expires time.Duration
|
||||
deadline time.Time
|
||||
}
|
||||
|
||||
// DefaultCacheExpiry should be used by cache instantiations without
|
||||
// any particular requirements.
|
||||
const DefaultCacheExpiry = time.Minute * 15
|
||||
|
||||
// ErrNoCache is returned on a cache lookup that did not yield a result
|
||||
var ErrNoCache = errors.New("nothing found in cache")
|
||||
|
||||
func (c *defaultCache) Get() ([][]byte, error) {
|
||||
if c.value != nil && time.Now().Before(c.deadline) {
|
||||
return *c.value, nil
|
||||
}
|
||||
return nil, ErrNoCache
|
||||
}
|
||||
|
||||
func (c *defaultCache) Set(value [][]byte) {
|
||||
c.deadline = time.Now().Add(c.expires)
|
||||
c.value = &value
|
||||
}
|
||||
|
||||
// NewDefaultKeyCache creates a simple cache that will hold a single
|
||||
// value for the given expiration time
|
||||
func NewDefaultKeyCache(expires time.Duration) Cache {
|
||||
return &defaultCache{
|
||||
expires: expires,
|
||||
}
|
||||
}
|
@ -1,279 +0,0 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/lestrrat-go/jwx/jwa"
|
||||
|
||||
"github.com/lestrrat-go/jwx/jwt"
|
||||
)
|
||||
|
||||
const publicKey = `
|
||||
-----BEGIN RSA PUBLIC KEY-----
|
||||
MIIBCgKCAQEAl2ifzOsh6AMRHZBe8xycvk01s3EAGT12WbgV9Z7b420Dj3NXrkns
|
||||
N/jvBbtO9cjQg4WM7NPZLs+ZutRkHCtMxt7vB0kjOYetLPcGdObsVBB5k1jvwvsJ
|
||||
HkcfmSsZdrV0Lz2Yxuf6ADWkxBqAY3GsS0zW0A2nIMc+41ZxqsZa3ProsKJxecRX
|
||||
SSZMpZtqCGt/S83Rek4eAahllcWfZpQmoEk7usLuUl5tH2TmaW3e5lo0JNfdwcq5
|
||||
PMCa8WSZBFH3YzVttB8rbe7a7336wL2NJQFz6dswL5X1dECYpZ5TRtNgzQYa4V0W
|
||||
AeICq+EzigaTxrjDHc5urHqEosz1le7O4QIDAQAB
|
||||
-----END RSA PUBLIC KEY-----
|
||||
`
|
||||
|
||||
const privateKey = `
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCXaJ/M6yHoAxEd
|
||||
kF7zHJy+TTWzcQAZPXZZuBX1ntvjbQOPc1euSew3+O8Fu071yNCDhYzs09kuz5m6
|
||||
1GQcK0zG3u8HSSM5h60s9wZ05uxUEHmTWO/C+wkeRx+ZKxl2tXQvPZjG5/oANaTE
|
||||
GoBjcaxLTNbQDacgxz7jVnGqxlrc+uiwonF5xFdJJkylm2oIa39LzdF6Th4BqGWV
|
||||
xZ9mlCagSTu6wu5SXm0fZOZpbd7mWjQk193Byrk8wJrxZJkEUfdjNW20Hytt7trv
|
||||
ffrAvY0lAXPp2zAvlfV0QJilnlNG02DNBhrhXRYB4gKr4TOKBpPGuMMdzm6seoSi
|
||||
zPWV7s7hAgMBAAECggEAYoPOxjSP4ThtoIDZZvHNAv2V3WW/HK0jHolqsGBmznmW
|
||||
AXaZLGwo6NpuG5qea8n38jupUEcfXxfw/OFJKhL6Z8OSX3k1FC+1fDZW2yWNy7zU
|
||||
fg02I/XXHv5EDxM+BEFYkYxQpcs2nYBJ7tcXhpzl8DDU7JaVkfxSbPVIDEf3wyP2
|
||||
k6DjYEeAj7uAsp50/32H9zhlJP/cFZaPiyFYy9/gOmDenrPVyJ/f7iQYNwYAwdbt
|
||||
yfp11Wd5BpePR58+YXICE8oBtzHvB50akK6RZULC3xHVxLQQ1bSxx6vnttxw5RW+
|
||||
QRHTVWtRyWiKe/l5jMvVSUo5XCLqsL2iXfR4bz6hyQKBgQDJQVWEGHyD6JyaN/6i
|
||||
5M5+O/YTbzMBgt1JAuVR2c0HYE4LpgrX4cA4kT3Bsa/Z9o0uuWVuxVab5gLsjsDu
|
||||
EI4o+HJQ3pl4LF9xqrndTdybwmZAT6jv3rM/VGfaCDzXCPzVx169I+WsfkyGW7Tr
|
||||
Cj4KDZyykruG/9OrpN1Aeq9a3wKBgQDAmCnQHLEPvZdezJGBc34HjZntrbW67iFB
|
||||
L0waCGWydyunYmzfja1FSvlSoziZdqoq0N4+uBQFPZIlERvq0zMgIzNFvxt/WlFV
|
||||
kQcBV24MNa8dtd+P7GDY8TzTfYBeXwJoi5c59sWLzSwpTlw0rI+ZvuA66eEF7xih
|
||||
eWw3k1jOPwKBgQCKf8DHGEbQTEtBQlmlZkrIyqDs/PCgEJwSe8Cu1HFpqxfqokkC
|
||||
CiTLiQB0BMEdAbRlPEcWtQ2GWgMXIqKY8qGyhk+9YYNCFV9VjQU9zDCOrHjLt0Zu
|
||||
VNcMNR0HCfY8kb3VrM+A4GxVidFGAWR+/9xz9KwqpBoTrIjRrbJphkSZBwKBgBMs
|
||||
0zTqNmLH0JNasL3/vrOH0KSOYAKdhOgVinEpFt7+6HTA4vAbDf5RKaOlppP48ZZT
|
||||
t1ztPOkMqUlRe8MUhgmUF53BGj7CwkhPqS/kAYvrqGS/3+NXeIkA87pmy2oZ8YZx
|
||||
J3xY6nAx3Ey8hYelCqMXEwIqmQHbPUuOaEzcOcJHAoGANw0TRha2YSbUqS3HiGR/
|
||||
Jm0lNfeLc3cYlEq0ggRDPLD11485rxVKnaKVHGPYW28OQ4jA+GCc7VZCfPV8VQXW
|
||||
6b+jUnnBwu/KuYvGMee/xJv1c4MTG54mR9UrUt+R80S0OplpcYkcQnft2Bi+AZ1h
|
||||
5aZTE7XIouXCYiMKPl4AMtI=
|
||||
-----END PRIVATE KEY-----
|
||||
`
|
||||
|
||||
func TestJWTProtect(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cookie *http.Cookie
|
||||
headers *http.Header
|
||||
server *httptest.Server
|
||||
authorizer func(r *http.Request, claims map[string]interface{}) error
|
||||
expectedStatusCode int
|
||||
}{
|
||||
{
|
||||
"no cookie",
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
func(r *http.Request, claims map[string]interface{}) error { return nil },
|
||||
http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
"bad server",
|
||||
&http.Cookie{
|
||||
Name: "auth",
|
||||
Value: "irrelevantgibberish",
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
func(r *http.Request, claims map[string]interface{}) error { return nil },
|
||||
http.StatusInternalServerError,
|
||||
},
|
||||
{
|
||||
"non-json response",
|
||||
&http.Cookie{
|
||||
Name: "auth",
|
||||
Value: "irrelevantgibberish",
|
||||
},
|
||||
nil,
|
||||
httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("here's some bytes 4 y'all"))
|
||||
})),
|
||||
func(r *http.Request, claims map[string]interface{}) error { return nil },
|
||||
http.StatusInternalServerError,
|
||||
},
|
||||
{
|
||||
"bad key value",
|
||||
&http.Cookie{
|
||||
Name: "auth",
|
||||
Value: "irrelevantgibberish",
|
||||
},
|
||||
nil,
|
||||
httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`{"keys":["not really a key","me neither"]}`))
|
||||
})),
|
||||
func(r *http.Request, claims map[string]interface{}) error { return nil },
|
||||
http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
"invalid key",
|
||||
&http.Cookie{
|
||||
Name: "auth",
|
||||
Value: "irrelevantgibberish",
|
||||
},
|
||||
nil,
|
||||
httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`{"keys":["-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCATZAMIIBCgKCAQEA2yUfHH6SRYKvBTemrefi\nHk4L4qkcc4skl4QCaHOkfgA4VcGKG2nXysYuZK7AzNOcHQVi+e4BwN+BfIZtwEU5\n7Ogctb5eg8ksxxLjS7eSRfQIvPGfAbJ12R9OoOWcue/CdUy/YMec4R/o4+tZ45S6\nQQWIMhLqYljw+s1Runda3K8Q8lOdJ4yEZckXaZr1waNJikC7oGpT7ClAgdbvWIbo\nN18G1OluRn+3WNdcN6V+vIj8c9dGs92bgTPX4cn3RmB/80BDfzeFiPMRw5xaq66F\n42zXzllkTqukQPk2wmO5m9pFy0ciRve+awfgbTtZRZOEpTSWLbbpOfd4RQ5YqDWJ\nmQIDAQAB\n-----END PUBLIC KEY-----"]}`))
|
||||
})),
|
||||
func(r *http.Request, claims map[string]interface{}) error { return nil },
|
||||
http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
"valid key, bad auth token",
|
||||
&http.Cookie{
|
||||
Name: "auth",
|
||||
Value: "irrelevantgibberish",
|
||||
},
|
||||
nil,
|
||||
httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(
|
||||
fmt.Sprintf(`{"keys":["%s"]}`, strings.ReplaceAll(publicKey, "\n", `\n`)),
|
||||
))
|
||||
})),
|
||||
func(r *http.Request, claims map[string]interface{}) error { return nil },
|
||||
http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
"valid key, valid token",
|
||||
&http.Cookie{
|
||||
Name: "auth",
|
||||
Value: (func() string {
|
||||
token := jwt.New()
|
||||
token.Set("exp", time.Now().Add(time.Hour))
|
||||
keyBytes, _ := pem.Decode([]byte(privateKey))
|
||||
privKey, _ := x509.ParsePKCS8PrivateKey(keyBytes.Bytes)
|
||||
b, _ := token.Sign(jwa.RS256, privKey)
|
||||
return string(b)
|
||||
})(),
|
||||
},
|
||||
nil,
|
||||
httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(
|
||||
fmt.Sprintf(`{"keys":["%s"]}`, strings.ReplaceAll(publicKey, "\n", `\n`)),
|
||||
))
|
||||
})),
|
||||
func(r *http.Request, claims map[string]interface{}) error { return nil },
|
||||
http.StatusOK,
|
||||
},
|
||||
{
|
||||
"ok token in headers",
|
||||
nil,
|
||||
(func() *http.Header {
|
||||
token := jwt.New()
|
||||
token.Set("exp", time.Now().Add(time.Hour))
|
||||
keyBytes, _ := pem.Decode([]byte(privateKey))
|
||||
privKey, _ := x509.ParsePKCS8PrivateKey(keyBytes.Bytes)
|
||||
b, _ := token.Sign(jwa.RS256, privKey)
|
||||
return &http.Header{
|
||||
"X-RPC-Authentication": []string{string(b)},
|
||||
}
|
||||
})(),
|
||||
httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(
|
||||
fmt.Sprintf(`{"keys":["%s"]}`, strings.ReplaceAll(publicKey, "\n", `\n`)),
|
||||
))
|
||||
})),
|
||||
func(r *http.Request, claims map[string]interface{}) error { return nil },
|
||||
http.StatusOK,
|
||||
},
|
||||
{
|
||||
"bad token in headers",
|
||||
nil,
|
||||
(func() *http.Header {
|
||||
return &http.Header{
|
||||
"X-RPC-Authentication": []string{"nilly willy"},
|
||||
}
|
||||
})(),
|
||||
httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(
|
||||
fmt.Sprintf(`{"keys":["%s"]}`, strings.ReplaceAll(publicKey, "\n", `\n`)),
|
||||
))
|
||||
})),
|
||||
func(r *http.Request, claims map[string]interface{}) error { return nil },
|
||||
http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
"authorizer rejects",
|
||||
&http.Cookie{
|
||||
Name: "auth",
|
||||
Value: (func() string {
|
||||
token := jwt.New()
|
||||
token.Set("exp", time.Now().Add(time.Hour))
|
||||
token.Set("priv", map[string]interface{}{"ok": false})
|
||||
keyBytes, _ := pem.Decode([]byte(privateKey))
|
||||
privKey, _ := x509.ParsePKCS8PrivateKey(keyBytes.Bytes)
|
||||
b, _ := token.Sign(jwa.RS256, privKey)
|
||||
return string(b)
|
||||
})(),
|
||||
},
|
||||
nil,
|
||||
httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(
|
||||
fmt.Sprintf(`{"keys":["%s"]}`, strings.ReplaceAll(publicKey, "\n", `\n`)),
|
||||
))
|
||||
})),
|
||||
func(r *http.Request, claims map[string]interface{}) error {
|
||||
if claims["ok"] == true {
|
||||
return nil
|
||||
}
|
||||
return errors.New("expected ok to be true")
|
||||
},
|
||||
http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
"valid key, expired token",
|
||||
&http.Cookie{
|
||||
Name: "auth",
|
||||
Value: (func() string {
|
||||
token := jwt.New()
|
||||
token.Set("exp", time.Now().Add(-time.Hour))
|
||||
keyBytes, _ := pem.Decode([]byte(privateKey))
|
||||
privKey, _ := x509.ParsePKCS8PrivateKey(keyBytes.Bytes)
|
||||
b, _ := token.Sign(jwa.RS256, privKey)
|
||||
return string(b)
|
||||
})(),
|
||||
},
|
||||
nil,
|
||||
httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(
|
||||
fmt.Sprintf(`{"keys":["%s"]}`, strings.ReplaceAll(publicKey, "\n", `\n`)),
|
||||
))
|
||||
})),
|
||||
func(r *http.Request, claims map[string]interface{}) error { return nil },
|
||||
http.StatusForbidden,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var url string
|
||||
if test.server != nil {
|
||||
url = test.server.URL
|
||||
}
|
||||
wrappedHandler := JWTProtect(url, "auth", "X-RPC-Authentication", test.authorizer, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("OK"))
|
||||
}))
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
if test.cookie != nil {
|
||||
r.AddCookie(test.cookie)
|
||||
}
|
||||
if test.headers != nil {
|
||||
for key, value := range *test.headers {
|
||||
r.Header.Add(key, value[0])
|
||||
}
|
||||
}
|
||||
wrappedHandler.ServeHTTP(w, r)
|
||||
if w.Code != test.expectedStatusCode {
|
||||
t.Errorf("Unexpected status code %v", w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
@ -1,64 +0,0 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// CorsMiddleware ensures the wrapped handler will respond with proper CORS
|
||||
// headers using the given origin.
|
||||
func CorsMiddleware(origin string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "POST,GET")
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ContentTypeMiddleware ensuresthe wrapped handler will respond with a
|
||||
// content type header of the given value.
|
||||
func ContentTypeMiddleware(contentType string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Add("Content-Type", contentType)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// OptoutMiddleware drops all requests to the given handler that are sent with
|
||||
// a cookie of the given name,
|
||||
func OptoutMiddleware(cookieName string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, err := r.Cookie(cookieName); err == nil {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// UserCookieMiddleware ensures a cookie of the given name is present and
|
||||
// attaches its value to the request's context using the given key, before
|
||||
// passing it on to the wrapped handler.
|
||||
func UserCookieMiddleware(cookieKey string, contextKey interface{}) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := r.Cookie(cookieKey)
|
||||
if err != nil {
|
||||
RespondWithJSONError(w, errors.New("user cookie: received no or blank identifier"), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
r = r.WithContext(
|
||||
context.WithValue(r.Context(), contextKey, c.Value),
|
||||
)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
@ -1,122 +0,0 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCorsMiddleware(t *testing.T) {
|
||||
t.Run("default", func(t *testing.T) {
|
||||
wrapped := CorsMiddleware("https://www.example.net")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("OK"))
|
||||
}))
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
wrapped.ServeHTTP(w, r)
|
||||
if h := w.Header().Get("Access-Control-Allow-Origin"); h != "https://www.example.net" {
|
||||
t.Errorf("Unexpected header value %v", h)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestContentTypeMiddleware(t *testing.T) {
|
||||
t.Run("default", func(t *testing.T) {
|
||||
wrapped := ContentTypeMiddleware("application/json")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("OK"))
|
||||
}))
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
wrapped.ServeHTTP(w, r)
|
||||
if h := w.Header().Get("Content-Type"); h != "application/json" {
|
||||
t.Errorf("Unexpected header value %v", h)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestOptoutMiddleware(t *testing.T) {
|
||||
wrapped := OptoutMiddleware("optout")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("hey there"))
|
||||
}))
|
||||
t.Run("with header", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.AddCookie(&http.Cookie{
|
||||
Name: "optout",
|
||||
})
|
||||
wrapped.ServeHTTP(w, r)
|
||||
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Errorf("Unexpected status code %d", w.Code)
|
||||
}
|
||||
|
||||
if w.Body.String() != "" {
|
||||
t.Errorf("Unexpected response body %s", w.Body.String())
|
||||
}
|
||||
})
|
||||
t.Run("without header", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
wrapped.ServeHTTP(w, r)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Unexpected status code %d", w.Code)
|
||||
}
|
||||
|
||||
if w.Body.String() != "hey there" {
|
||||
t.Errorf("Unexpected response body %s", w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestUserCookieMiddleware(t *testing.T) {
|
||||
wrapped := UserCookieMiddleware("user", 1)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
value := r.Context().Value(1)
|
||||
fmt.Fprintf(w, "value is %v", value)
|
||||
}))
|
||||
t.Run("no cookie", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
wrapped.ServeHTTP(w, r)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Unexpected status code %v", w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "received no or blank identifier") {
|
||||
t.Errorf("Unexpected body %s", w.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no value", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
wrapped.ServeHTTP(w, r)
|
||||
r.AddCookie(&http.Cookie{
|
||||
Name: "user",
|
||||
Value: "",
|
||||
})
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Unexpected status code %v", w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "received no or blank identifier") {
|
||||
t.Errorf("Unexpected body %s", w.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ok", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.AddCookie(&http.Cookie{
|
||||
Name: "user",
|
||||
Value: "token",
|
||||
})
|
||||
wrapped.ServeHTTP(w, r)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Unexpected status code %v", w.Code)
|
||||
}
|
||||
if w.Body.String() != "value is token" {
|
||||
t.Errorf("Unexpected body %s", w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
425
styles/index.css
425
styles/index.css
@ -1,425 +0,0 @@
|
||||
/* Typo
|
||||
------------------------------------------------------------- */
|
||||
body {
|
||||
padding: 50px;
|
||||
background-color: #fff;
|
||||
color: #898989;
|
||||
font: 18px/1.5 "Noto Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
font-size: 18px;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
color: #404040;
|
||||
font-size: 28px;
|
||||
line-height: 1.4;
|
||||
font-weight: 400;
|
||||
margin: 0 0 40px 0;
|
||||
}
|
||||
|
||||
h3,
|
||||
h4 {
|
||||
font-size: 18px;
|
||||
margin: 30px 0 20px 0;
|
||||
}
|
||||
|
||||
.section-auditorium h3 {
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
|
||||
h4 {
|
||||
color: #898989;
|
||||
margin: 30px 0 6px 0;
|
||||
}
|
||||
|
||||
.section-auditorium h4 {
|
||||
margin: 30px 0 20px 0;
|
||||
}
|
||||
|
||||
|
||||
/* Links
|
||||
------------------------------------------------------------- */
|
||||
a,
|
||||
a:hover,
|
||||
a:focus {
|
||||
color: #898989;
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: inherit;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
h1 a,
|
||||
h1 a:hover,
|
||||
h1 a:focus {
|
||||
color: #f7bf08;
|
||||
text-decoration: none;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
small a,
|
||||
small a:hover,
|
||||
small a:focus {
|
||||
color: #898989;
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
sup {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
sup a,
|
||||
sup a:hover,
|
||||
sup a:focus {
|
||||
color: #cfcfcf;
|
||||
}
|
||||
|
||||
ol {
|
||||
padding-inline-start: 20px;
|
||||
}
|
||||
|
||||
ol li p a,
|
||||
ol li p a:hover,
|
||||
ol li p a:focus {
|
||||
color: #cfcfcf;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/* Typo Elements
|
||||
------------------------------------------------------------- */
|
||||
.logo {
|
||||
margin: -20px 0 0 0;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-left: 1px solid #e5e5e5;
|
||||
margin: 0;
|
||||
padding: 0 0 0 20px;
|
||||
font-style: italic
|
||||
}
|
||||
|
||||
hr {
|
||||
border-top: 1px solid #d5d5d5;
|
||||
height: 0;
|
||||
margin: 80px 0 0 0;
|
||||
}
|
||||
|
||||
.footnote hr {
|
||||
border: 0;
|
||||
height: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-left: 1px solid #d5d5d5;
|
||||
margin: 40px 0 40px 0;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: 700
|
||||
}
|
||||
|
||||
strong,
|
||||
h1 strong,
|
||||
h2 strong,
|
||||
h3 strong {
|
||||
color: #404040;
|
||||
text-decoration: none;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
h1 strong,
|
||||
h2 strong,
|
||||
h3 strong {
|
||||
color: #898989;
|
||||
}
|
||||
|
||||
tbody,
|
||||
h4 strong {
|
||||
color: #404040;
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
h4 strong {
|
||||
font-size: 36px;
|
||||
}
|
||||
|
||||
|
||||
/* Buttons
|
||||
------------------------------------------------------------- */
|
||||
.btn {
|
||||
-webkit-border-radius: 0;
|
||||
-moz-border-radius: 0;
|
||||
border-radius: 0px;
|
||||
padding: 6px;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-color-grey,
|
||||
.btn-color-grey:visited,
|
||||
.btn-color-grey:link {
|
||||
background: #fff;
|
||||
color: #898989;
|
||||
border: solid #898989 5px;
|
||||
}
|
||||
|
||||
.btn-color-orange,
|
||||
.btn-color-orange:visited,
|
||||
.btn-color-orange:link {
|
||||
background: #fff;
|
||||
color: #F7BF08;
|
||||
border: solid #F7BF08 5px;
|
||||
}
|
||||
|
||||
.btn-color-grey:hover,
|
||||
.btn-color-grey:active {
|
||||
background: #898989;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-color-orange:hover,
|
||||
.btn-color-orange:active {
|
||||
background: #F7BF08;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn {
|
||||
margin: 0 0 15px 0;
|
||||
}
|
||||
|
||||
.button-wrapper {
|
||||
display: flex;
|
||||
flex-flow: column wrap;
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
.navigation-wrapper {
|
||||
margin: 60px 0 0 0;
|
||||
}
|
||||
|
||||
@media (min-width: 480px) {
|
||||
.button-wrapper {
|
||||
align-items: center;
|
||||
flex-flow: row wrap;
|
||||
-ms-flex-flow: row wrap;
|
||||
-webkit-flex-flow: row wrap;
|
||||
}
|
||||
.btn {
|
||||
flex-grow: 1;
|
||||
}
|
||||
.btn {
|
||||
margin: 0 15px 15px 0;
|
||||
}
|
||||
.btn:last-of-type {
|
||||
margin: 0 0 15px 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/* Custom Underline
|
||||
------------------------------------------------------------- */
|
||||
em {
|
||||
color: #898989;
|
||||
background: linear-gradient(transparent 66%, #fde28c 66%);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/* Layout
|
||||
------------------------------------------------------------- */
|
||||
.wrapper {
|
||||
width: 860px;
|
||||
margin: 0 auto
|
||||
}
|
||||
|
||||
header {
|
||||
width: 270px;
|
||||
float: left;
|
||||
position: fixed;
|
||||
-webkit-font-smoothing: subpixel-antialiased
|
||||
}
|
||||
|
||||
section,
|
||||
section-auditorium {
|
||||
float: right;
|
||||
width: 600px;
|
||||
padding: 0 0 40px 0;
|
||||
}
|
||||
|
||||
section > p,
|
||||
section-auditorium > p {
|
||||
margin: 0 0 20px 0;
|
||||
}
|
||||
|
||||
section > p:last-child,
|
||||
section-auditorium > p:last-child {
|
||||
margin: 0 0 60px 0;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.row h4 {
|
||||
margin-right: 30px;
|
||||
}
|
||||
|
||||
.row h4:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/* Tables
|
||||
------------------------------------------------------------- */
|
||||
.table-full-width {
|
||||
box-sizing: border-box
|
||||
}
|
||||
|
||||
td {
|
||||
padding-right: 30px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/* Footer
|
||||
------------------------------------------------------------- */
|
||||
footer {
|
||||
width: 270px;
|
||||
float: left;
|
||||
position: fixed;
|
||||
bottom: 100px;
|
||||
-webkit-font-smoothing: subpixel-antialiased
|
||||
}
|
||||
|
||||
.footnote {
|
||||
color: #d5d5d5;
|
||||
font-size: 14px;
|
||||
margin: 20px 0 0 0;
|
||||
}
|
||||
|
||||
.footnote ol {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.footer-list {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.footer-list li {
|
||||
display: inline-block;
|
||||
font-size: 14px;
|
||||
margin-right: 0.14em;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/* Media Queries
|
||||
------------------------------------------------------------- */
|
||||
@media screen and (max-width: 960px) {
|
||||
div.wrapper {
|
||||
width: auto;
|
||||
margin: 0
|
||||
}
|
||||
header,
|
||||
section,
|
||||
section-auditorium,
|
||||
footer {
|
||||
float: none;
|
||||
position: static;
|
||||
width: auto
|
||||
}
|
||||
header {
|
||||
padding-right: 320px
|
||||
}
|
||||
section,
|
||||
section-auditorium {
|
||||
border: none;
|
||||
width: auto;
|
||||
padding: 0 0 0 0;
|
||||
margin: 40px 0 40px 0;
|
||||
}
|
||||
section > p:first-child,
|
||||
section > p:last-child,
|
||||
section-auditorium > p:first-child,
|
||||
section-auditorium > p:last-child {
|
||||
margin: 0 0 40px 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@media screen and (max-width: 480px) {
|
||||
body {
|
||||
font-size: 14px;
|
||||
line-height: 1.3;
|
||||
padding: 15px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
h1,
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
line-height: 1.3;
|
||||
margin: 0 0 20px 0;
|
||||
}
|
||||
h3,
|
||||
h4 {
|
||||
font-size: 14px;
|
||||
line-height: 1.3;
|
||||
margin: 30px 0 15px 0;
|
||||
}
|
||||
h4 strong {
|
||||
font-size: 28px;
|
||||
}
|
||||
header {
|
||||
padding: 0
|
||||
}
|
||||
.logo {
|
||||
margin: 0;
|
||||
}
|
||||
hr {
|
||||
margin: 60px 0 0 0;
|
||||
}
|
||||
blockquote {
|
||||
margin: 15px 0 15px 0;
|
||||
}
|
||||
.row {
|
||||
justify-content: space-between;
|
||||
}
|
||||
.row h4:first-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
.table-full-width {
|
||||
width: 100%;
|
||||
}
|
||||
td {
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user