2
0
mirror of https://github.com/offen/website.git synced 2024-11-25 02:10:26 +01:00

remove accounts app

This commit is contained in:
Frederik Ring 2019-09-03 19:46:38 +02:00
parent 45c9b104f5
commit c9e40d8df5
25 changed files with 1 additions and 1177 deletions

View File

@ -6,7 +6,6 @@ production_env: &production_env
- 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
@ -19,7 +18,6 @@ deploy_preconditions: &deploy_preconditions
- auditorium
- packages
- shared
- accounts
filters:
branches:
only: /^master$/
@ -173,75 +171,6 @@ jobs:
name: Run tests
command: npm test
accounts:
docker:
- image: circleci/python:3.6
environment:
CONFIG_CLASS: accounts.config.LocalConfig
MYSQL_CONNECTION_STRING: mysql://root:circle@127.0.0.1:3306/circle
- image: circleci/mysql:5.7
environment:
- MYSQL_ROOT_PASSWORD=circle
- MYSQL_DATABASE=circle
- MYSQL_HOST=127.0.0.1
working_directory: ~/offen/accounts
steps:
- checkout:
path: ~/offen
- restore_cache:
key: offen-accounts-{{ checksum "requirements.txt" }}-{{ checksum "requirements-dev.txt" }}
- run:
name: Install dependencies
command: |
python3 -m venv venv
. venv/bin/activate
pip install -r requirements.txt
pip install -r requirements-dev.txt
- save_cache:
paths:
- ~/offen/accounts/venv
key: offen-accounts-{{ checksum "requirements.txt" }}-{{ checksum "requirements-dev.txt" }}
- run:
name: Waiting for MySQL to be ready
command: |
for i in `seq 1 10`;
do
nc -z localhost 3306 && echo Success && exit 0
echo -n .
sleep 1
done
echo Failed waiting for MySQL && exit 1
- run:
name: Run tests
command: |
. venv/bin/activate
cp ~/offen/bootstrap.yml .
make test-ci
deploy_python:
docker:
- image: circleci/python:3.6-node
<<: *production_env
working_directory: ~/offen
steps:
- checkout:
path: ~/offen
- restore_cache:
key: offen-deploy-{{ checksum "package.json" }}
- run:
name: Install dependencies
command: npm install
- save_cache:
paths:
- ~/offen/packages/node_modules
key: offen-packages-{{ checksum "package.json" }}
- run:
name: Deploy
working_directory: ~/offen/accounts
command: |
echo "Deploying accounts ..."
$(npm bin)/sls deploy
deploy_golang:
docker:
- image: circleci/golang:1.12-node
@ -412,8 +341,6 @@ workflows:
<<: *build_preconditions
- shared:
<<: *build_preconditions
- accounts:
<<: *build_preconditions
- deploy_golang:
<<: *deploy_preconditions
- deploy_node:

8
accounts/.gitignore vendored
View File

@ -1,8 +0,0 @@
*.pem
*.dot
__pycache__
*.log
.vscode/
.pytest_cache
venv/
*.pyc

View File

@ -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

View File

@ -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
```

View File

@ -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))

View File

@ -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"]})

View File

@ -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"])

View File

@ -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")

View File

@ -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 %}

View File

@ -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

View File

View File

@ -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)

View File

@ -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()}

View File

@ -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)
)

View File

@ -1,3 +0,0 @@
pytest
black
pyyaml

View File

@ -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

View File

@ -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")

View File

@ -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)

View File

@ -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'

View File

@ -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")

View File

@ -31,7 +31,6 @@ services:
POSTGRES_CONNECTION_STRING: postgres://postgres:develop@server_database:5432/postgres?sslmode=disable
KMS_ENCRYPTION_ENDPOINT: http://kms:8081/encrypt
PORT: 8080
JWT_PUBLIC_KEY: http://accounts:5000/api/key
DEVELOPMENT: '1'
COOKIE_EXCHANGE_SECRET: Wsttdo4Z3mXV5sTc
EVENT_RETENTION_PERIOD: 4464h
@ -59,7 +58,6 @@ services:
- KMS_HOST=http://localhost:8081
- SCRIPT_HOST=http://localhost:9977
- AUDITORIUM_HOST=http://localhost:8080
- ACCOUNTS_HOST=http://localhost:5000
- HOMEPAGE_HOST=http://localhost:8000
script:

View File

@ -14,12 +14,9 @@
"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"
"serverless-pseudo-parameters": "^2.4.0"
},
"devDependencies": {}
}