mirror of
https://github.com/offen/website.git
synced 2024-11-22 09:00: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
|
version: 2
|
||||||
|
|
||||||
production_env: &production_env
|
build_preconditions: &build_preconditions
|
||||||
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
|
|
||||||
requires:
|
requires:
|
||||||
- server
|
- server
|
||||||
- kms
|
|
||||||
- vault
|
- vault
|
||||||
- script
|
- script
|
||||||
- auditorium
|
- auditorium
|
||||||
- packages
|
- packages
|
||||||
- shared
|
|
||||||
- accounts
|
|
||||||
filters:
|
filters:
|
||||||
branches:
|
branches:
|
||||||
only: /^master$/
|
only: /^master$/
|
||||||
|
|
||||||
build_preconditions: &build_preconditions
|
deploy_preconditions: &deploy_preconditions
|
||||||
|
requires:
|
||||||
|
- build
|
||||||
filters:
|
filters:
|
||||||
branches:
|
branches:
|
||||||
ignore: gh-pages
|
only: /^master$/
|
||||||
|
|
||||||
jobs:
|
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:
|
server:
|
||||||
docker:
|
docker:
|
||||||
- image: circleci/golang:1.12
|
- image: circleci/golang:1.12
|
||||||
environment:
|
environment:
|
||||||
- POSTGRES_CONNECTION_STRING=postgres://circle:test@localhost:5432/circle_test?sslmode=disable
|
POSTGRES_CONNECTION_STRING: postgres://circle:test@localhost:5432/circle_test?sslmode=disable
|
||||||
- PORT=8080
|
PORT: 8080
|
||||||
- EVENT_RETENTION_PERIOD=4464h
|
EVENT_RETENTION_PERIOD: 4464h
|
||||||
|
COOKIE_EXCHANGE_SECRET: VswgMshC4mPDfey8o+yScg==
|
||||||
- image: circleci/postgres:11.2-alpine
|
- image: circleci/postgres:11.2-alpine
|
||||||
environment:
|
environment:
|
||||||
- POSTGRES_USER=circle
|
- POSTGRES_USER=circle
|
||||||
@ -97,20 +60,6 @@ jobs:
|
|||||||
cp ~/offen/bootstrap.yml .
|
cp ~/offen/bootstrap.yml .
|
||||||
make test-ci
|
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:
|
vault:
|
||||||
docker:
|
docker:
|
||||||
- image: circleci/node:10-browsers
|
- image: circleci/node:10-browsers
|
||||||
@ -200,262 +149,75 @@ jobs:
|
|||||||
name: Run tests
|
name: Run tests
|
||||||
command: npm test
|
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:
|
docker:
|
||||||
- image: circleci/python:3.6
|
- 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:
|
steps:
|
||||||
- checkout:
|
- checkout
|
||||||
path: ~/offen
|
|
||||||
- restore_cache:
|
|
||||||
key: offen-accounts-{{ checksum "requirements.txt" }}-{{ checksum "requirements-dev.txt" }}
|
|
||||||
- run:
|
- run:
|
||||||
name: Install dependencies
|
name: Installing deployment dependencies
|
||||||
command: |
|
command: |
|
||||||
python3 -m venv venv
|
sudo pip install awsebcli --upgrade
|
||||||
. 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" }}
|
|
||||||
- run:
|
- run:
|
||||||
name: Waiting for MySQL to be ready
|
name: Deploying
|
||||||
command: |
|
command: |
|
||||||
for i in `seq 1 10`;
|
cp Dockerrun.aws.json.production Dockerrun.aws.json
|
||||||
do
|
eb deploy
|
||||||
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
|
|
||||||
|
|
||||||
workflows:
|
workflows:
|
||||||
version: 2
|
version: 2
|
||||||
test_build_deploy:
|
test_build_deploy:
|
||||||
jobs:
|
jobs:
|
||||||
- server:
|
- server
|
||||||
|
- vault
|
||||||
|
- script
|
||||||
|
- auditorium
|
||||||
|
- packages
|
||||||
|
- build:
|
||||||
<<: *build_preconditions
|
<<: *build_preconditions
|
||||||
- kms:
|
- deploy:
|
||||||
<<: *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_preconditions
|
<<: *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/
|
venv/
|
||||||
|
|
||||||
bootstrap-alpha.yml
|
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:
|
help:
|
||||||
@echo " setup"
|
@echo " setup"
|
||||||
@echo " Build the containers and install dependencies."
|
@echo " Build the development containers and install dependencies."
|
||||||
@echo " bootstrap"
|
@echo " bootstrap"
|
||||||
@echo " Set up keys and seed databases."
|
@echo " Set up keys and seed databases."
|
||||||
@echo " IMPORTANT: this wipes any existing data in your local database."
|
@echo " IMPORTANT: this wipes any existing data in your local database."
|
||||||
|
@echo " build"
|
||||||
|
@echo " Build the production containers."
|
||||||
|
|
||||||
setup:
|
setup:
|
||||||
@docker-compose build
|
@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 script npm install
|
||||||
@docker-compose run vault npm install
|
@docker-compose run vault npm install
|
||||||
@docker-compose run auditorium npm install
|
@docker-compose run auditorium npm install
|
||||||
@docker-compose run server go mod download
|
@docker-compose run server go mod download
|
||||||
@docker-compose run kms go mod download
|
|
||||||
@echo "Successfully built containers and installed dependencies."
|
@echo "Successfully built containers and installed dependencies."
|
||||||
@echo "If this is your initial setup, you can run 'make bootstrap' next"
|
@echo "If this is your initial setup, you can run 'make bootstrap' next"
|
||||||
@echo "to create the needed local keys and seed the database."
|
@echo "to create the needed local keys and seed the database."
|
||||||
|
|
||||||
bootstrap:
|
bootstrap:
|
||||||
@echo "Bootstrapping KMS service ..."
|
|
||||||
@docker-compose run kms make bootstrap
|
|
||||||
@echo "Bootstrapping Server service ..."
|
@echo "Bootstrapping Server service ..."
|
||||||
@docker-compose run server make bootstrap
|
@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.
|
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
|
$ docker-compose up
|
||||||
```
|
```
|
||||||
|
|
||||||
which should enable you to access <http://localhost:9955/> and use the `auditorium`
|
which should enable you to access <http://localhost:8080/auditorium/> 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>.
|
|
||||||
|
|
||||||
### License
|
### 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'
|
version: '3'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
kms:
|
proxy:
|
||||||
build:
|
image: nginx:1.17-alpine
|
||||||
context: '.'
|
|
||||||
dockerfile: Dockerfile.golang
|
|
||||||
working_dir: /offen/kms
|
|
||||||
volumes:
|
volumes:
|
||||||
- .:/offen
|
- ./nginx.conf:/etc/nginx/nginx.conf
|
||||||
- kmsdeps:/go/pkg/mod
|
- ./styles:/code/styles
|
||||||
environment:
|
|
||||||
KEY_FILE: key.txt
|
|
||||||
PORT: 8081
|
|
||||||
CORS_ORIGIN: http://localhost:9977
|
|
||||||
JWT_PUBLIC_KEY: http://accounts:5000/api/key
|
|
||||||
ports:
|
ports:
|
||||||
- 8081:8081
|
- 8080:80
|
||||||
command: refresh run
|
depends_on:
|
||||||
links:
|
- homepage
|
||||||
- accounts
|
- server
|
||||||
|
- auditorium
|
||||||
server_database:
|
- vault
|
||||||
image: postgres:11.2
|
- script
|
||||||
environment:
|
|
||||||
POSTGRES_PASSWORD: develop
|
|
||||||
|
|
||||||
accounts_database:
|
|
||||||
image: mysql:5.7
|
|
||||||
ports:
|
|
||||||
- "3306:3306"
|
|
||||||
environment:
|
|
||||||
MYSQL_DATABASE: mysql
|
|
||||||
MYSQL_ROOT_PASSWORD: develop
|
|
||||||
|
|
||||||
server:
|
server:
|
||||||
build:
|
build:
|
||||||
@ -43,22 +25,20 @@ services:
|
|||||||
- ./bootstrap.yml:/offen/server/bootstrap.yml
|
- ./bootstrap.yml:/offen/server/bootstrap.yml
|
||||||
- serverdeps:/go/pkg/mod
|
- serverdeps:/go/pkg/mod
|
||||||
environment:
|
environment:
|
||||||
CORS_ORIGIN: http://localhost:9977
|
|
||||||
OPTOUT_COOKIE_DOMAIN: localhost
|
|
||||||
POSTGRES_CONNECTION_STRING: postgres://postgres:develop@server_database:5432/postgres?sslmode=disable
|
POSTGRES_CONNECTION_STRING: postgres://postgres:develop@server_database:5432/postgres?sslmode=disable
|
||||||
KMS_ENCRYPTION_ENDPOINT: http://kms:8081/encrypt
|
|
||||||
PORT: 8080
|
PORT: 8080
|
||||||
JWT_PUBLIC_KEY: http://accounts:5000/api/key
|
|
||||||
DEVELOPMENT: '1'
|
DEVELOPMENT: '1'
|
||||||
COOKIE_EXCHANGE_SECRET: Wsttdo4Z3mXV5sTc
|
COOKIE_EXCHANGE_SECRET: 3P+w6QetKO3Kn8h1YyRlCw==
|
||||||
EVENT_RETENTION_PERIOD: 4464h
|
EVENT_RETENTION_PERIOD: 4464h
|
||||||
ports:
|
ACCOUNT_USER_EMAIL_SALT: JuhbRA4lCdo8rt5qVdLpk3==
|
||||||
- 8080:8080
|
|
||||||
command: refresh run
|
command: refresh run
|
||||||
links:
|
links:
|
||||||
- server_database
|
- server_database
|
||||||
depends_on:
|
|
||||||
- kms
|
server_database:
|
||||||
|
image: postgres:11.2
|
||||||
|
environment:
|
||||||
|
POSTGRES_PASSWORD: develop
|
||||||
|
|
||||||
vault:
|
vault:
|
||||||
build:
|
build:
|
||||||
@ -69,15 +49,6 @@ services:
|
|||||||
- .:/offen
|
- .:/offen
|
||||||
- vaultdeps:/offen/vault/node_modules
|
- vaultdeps:/offen/vault/node_modules
|
||||||
command: npm start -- --port 9977
|
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:
|
script:
|
||||||
build:
|
build:
|
||||||
@ -88,10 +59,6 @@ services:
|
|||||||
- .:/offen
|
- .:/offen
|
||||||
- scriptdeps:/offen/script/node_modules
|
- scriptdeps:/offen/script/node_modules
|
||||||
command: npm start -- --port 9966
|
command: npm start -- --port 9966
|
||||||
ports:
|
|
||||||
- 9966:9966
|
|
||||||
environment:
|
|
||||||
- VAULT_HOST=http://localhost:9977
|
|
||||||
|
|
||||||
auditorium:
|
auditorium:
|
||||||
build:
|
build:
|
||||||
@ -102,38 +69,24 @@ services:
|
|||||||
- .:/offen
|
- .:/offen
|
||||||
- auditoriumdeps:/offen/auditorium/node_modules
|
- auditoriumdeps:/offen/auditorium/node_modules
|
||||||
command: npm start -- --port 9955
|
command: npm start -- --port 9955
|
||||||
ports:
|
|
||||||
- 9955:9955
|
|
||||||
environment:
|
|
||||||
- VAULT_HOST=http://localhost:9977
|
|
||||||
|
|
||||||
accounts:
|
homepage:
|
||||||
build:
|
build:
|
||||||
context: '.'
|
context: '.'
|
||||||
dockerfile: Dockerfile.python
|
dockerfile: ./Dockerfile.python
|
||||||
working_dir: /offen/accounts
|
working_dir: /offen/homepage
|
||||||
volumes:
|
volumes:
|
||||||
- .:/offen
|
- .:/offen
|
||||||
- ./bootstrap.yml:/offen/accounts/bootstrap.yml
|
- homepagedeps:/root/.local
|
||||||
- accountdeps:/root/.local
|
command: make devserver
|
||||||
command: flask run --host 0.0.0.0
|
|
||||||
ports:
|
ports:
|
||||||
- 5000:5000
|
- 8000:8000
|
||||||
links:
|
|
||||||
- accounts_database
|
|
||||||
environment:
|
environment:
|
||||||
CONFIG_CLASS: accounts.config.LocalConfig
|
DEBUG: 1
|
||||||
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
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
kmsdeps:
|
|
||||||
serverdeps:
|
serverdeps:
|
||||||
scriptdeps:
|
scriptdeps:
|
||||||
auditoriumdeps:
|
auditoriumdeps:
|
||||||
vaultdeps:
|
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__ 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">
|
<div class="btn-wrapper">
|
||||||
<a class="btn btn-color-yellow" href="/deep-dive/">Deep dive</a>
|
<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 *
|
from pelicanconf import *
|
||||||
|
|
||||||
# If your site is available via HTTPS, make sure SITEURL begins with https://
|
# If your site is available via HTTPS, make sure SITEURL begins with https://
|
||||||
SITEURL = 'https://www.offen.dev'
|
SITEURL = os.environ.get('SITEURL', 'https://www.offen.dev')
|
||||||
RELATIVE_URLS = False
|
# RELATIVE_URLS = True
|
||||||
|
|
||||||
FEED_ALL_ATOM = 'feeds/all.atom.xml'
|
FEED_ALL_ATOM = 'feeds/all.atom.xml'
|
||||||
CATEGORY_FEED_ATOM = 'feeds/{slug}.atom.xml'
|
CATEGORY_FEED_ATOM = 'feeds/{slug}.atom.xml'
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
;(function ($) {
|
;(function ($) {
|
||||||
$(document).ready(function () {
|
$(document).ready(function () {
|
||||||
$(window).scroll(function () {
|
$(window).scroll(function () {
|
||||||
var scrollProgress = parseInt($(window).scrollTop(), 10)
|
if ($(window).width() > 960) {
|
||||||
if ($(window).width() > 960) {
|
var scrollProgress = parseInt($(window).scrollTop(), 10)
|
||||||
$('body.index .brand').css('opacity', Math.min(scrollProgress / 100, 1))
|
$('body.index .brand').css('opacity', Math.min(scrollProgress / 100, 1))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})(window.jQuery)
|
})(window.jQuery)
|
||||||
|
@ -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="canonical" href="{{ SITEURL }}/{{ page.save_as }}">
|
||||||
<link rel="shortcut icon" type="image/x-icon" href="/theme/images/favicon.ico">
|
<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" %}
|
{% 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 %}
|
{% endassets %}
|
||||||
{% if OFFEN_ACCOUNT_ID %}
|
{% 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 %}
|
{% endif %}
|
||||||
</head>
|
</head>
|
||||||
<body class="{{page.template}}">
|
<body class="{{page.template}}">
|
||||||
@ -136,8 +136,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% assets filters="rjsmin", output="scripts/packed.js", "scripts/jquery-3.4.1.min.js", "scripts/menu.js", "scripts/fade.js" %}
|
{% block scripts %}
|
||||||
<script src="{{ SITEURL }}/{{ ASSET_URL }}"></script>
|
{% assets filters="rjsmin", output="scripts/packed.js", "scripts/jquery-3.4.1.min.js", "scripts/menu.js", "scripts/fade.js" %}
|
||||||
{% endassets %}
|
<script src="/{{ ASSET_URL }}"></script>
|
||||||
|
{% endassets %}
|
||||||
|
{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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