From c9e40d8df5a559967194f67b9027185afe11ab58 Mon Sep 17 00:00:00 2001 From: Frederik Ring Date: Tue, 3 Sep 2019 19:46:38 +0200 Subject: [PATCH] remove accounts app --- .circleci/config.yml | 73 --------- accounts/.gitignore | 8 - accounts/Makefile | 13 -- accounts/README.md | 37 ----- accounts/accounts/__init__.py | 25 --- accounts/accounts/api.py | 134 ---------------- accounts/accounts/config.py | 73 --------- accounts/accounts/models.py | 49 ------ accounts/accounts/templates/index.html | 17 -- accounts/accounts/views.py | 122 --------------- accounts/bootstrap.yml | 0 accounts/lambdas/__init__.py | 0 accounts/lambdas/authorizer.py | 80 ---------- accounts/lambdas/create_keys.py | 21 --- accounts/lambdas/rotate_keys.py | 123 --------------- accounts/requirements-dev.txt | 3 - accounts/requirements.txt | 12 -- accounts/scripts/__init__.py | 0 accounts/scripts/bootstrap.py | 41 ----- accounts/scripts/hash.py | 20 --- accounts/serverless.yml | 114 -------------- accounts/tests/__init__.py | 0 accounts/tests/test_api.py | 206 ------------------------- docker-compose.yml | 2 - package.json | 5 +- 25 files changed, 1 insertion(+), 1177 deletions(-) delete mode 100644 accounts/.gitignore delete mode 100644 accounts/Makefile delete mode 100644 accounts/README.md delete mode 100644 accounts/accounts/__init__.py delete mode 100644 accounts/accounts/api.py delete mode 100644 accounts/accounts/config.py delete mode 100644 accounts/accounts/models.py delete mode 100644 accounts/accounts/templates/index.html delete mode 100644 accounts/accounts/views.py delete mode 100755 accounts/bootstrap.yml delete mode 100644 accounts/lambdas/__init__.py delete mode 100644 accounts/lambdas/authorizer.py delete mode 100644 accounts/lambdas/create_keys.py delete mode 100644 accounts/lambdas/rotate_keys.py delete mode 100644 accounts/requirements-dev.txt delete mode 100644 accounts/requirements.txt delete mode 100644 accounts/scripts/__init__.py delete mode 100755 accounts/scripts/bootstrap.py delete mode 100644 accounts/scripts/hash.py delete mode 100644 accounts/serverless.yml delete mode 100644 accounts/tests/__init__.py delete mode 100644 accounts/tests/test_api.py diff --git a/.circleci/config.yml b/.circleci/config.yml index e25a036..56ce86d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -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: diff --git a/accounts/.gitignore b/accounts/.gitignore deleted file mode 100644 index f5bd592..0000000 --- a/accounts/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -*.pem -*.dot -__pycache__ -*.log -.vscode/ -.pytest_cache -venv/ -*.pyc diff --git a/accounts/Makefile b/accounts/Makefile deleted file mode 100644 index 2c9a929..0000000 --- a/accounts/Makefile +++ /dev/null @@ -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 diff --git a/accounts/README.md b/accounts/README.md deleted file mode 100644 index dd2fc7d..0000000 --- a/accounts/README.md +++ /dev/null @@ -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 -``` diff --git a/accounts/accounts/__init__.py b/accounts/accounts/__init__.py deleted file mode 100644 index 6c2260e..0000000 --- a/accounts/accounts/__init__.py +++ /dev/null @@ -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)) diff --git a/accounts/accounts/api.py b/accounts/accounts/api.py deleted file mode 100644 index aad2ab4..0000000 --- a/accounts/accounts/api.py +++ /dev/null @@ -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"]}) diff --git a/accounts/accounts/config.py b/accounts/accounts/config.py deleted file mode 100644 index 3c80bd1..0000000 --- a/accounts/accounts/config.py +++ /dev/null @@ -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"]) diff --git a/accounts/accounts/models.py b/accounts/accounts/models.py deleted file mode 100644 index 6068da1..0000000 --- a/accounts/accounts/models.py +++ /dev/null @@ -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") diff --git a/accounts/accounts/templates/index.html b/accounts/accounts/templates/index.html deleted file mode 100644 index 7a14a67..0000000 --- a/accounts/accounts/templates/index.html +++ /dev/null @@ -1,17 +0,0 @@ -{% extends 'admin/base.html' %} - -{% block head_css %} - - - - {% if admin_view.extra_css %} - {% for css_url in admin_view.extra_css %} - - {% endfor %} - {% endif %} - -{% endblock %} diff --git a/accounts/accounts/views.py b/accounts/accounts/views.py deleted file mode 100644 index e5921a3..0000000 --- a/accounts/accounts/views.py +++ /dev/null @@ -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 diff --git a/accounts/bootstrap.yml b/accounts/bootstrap.yml deleted file mode 100755 index e69de29..0000000 diff --git a/accounts/lambdas/__init__.py b/accounts/lambdas/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/accounts/lambdas/authorizer.py b/accounts/lambdas/authorizer.py deleted file mode 100644 index ddb510d..0000000 --- a/accounts/lambdas/authorizer.py +++ /dev/null @@ -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) diff --git a/accounts/lambdas/create_keys.py b/accounts/lambdas/create_keys.py deleted file mode 100644 index 3ba03f0..0000000 --- a/accounts/lambdas/create_keys.py +++ /dev/null @@ -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()} diff --git a/accounts/lambdas/rotate_keys.py b/accounts/lambdas/rotate_keys.py deleted file mode 100644 index ce8a10e..0000000 --- a/accounts/lambdas/rotate_keys.py +++ /dev/null @@ -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) - ) diff --git a/accounts/requirements-dev.txt b/accounts/requirements-dev.txt deleted file mode 100644 index bcfacb5..0000000 --- a/accounts/requirements-dev.txt +++ /dev/null @@ -1,3 +0,0 @@ -pytest -black -pyyaml diff --git a/accounts/requirements.txt b/accounts/requirements.txt deleted file mode 100644 index ef8b1a7..0000000 --- a/accounts/requirements.txt +++ /dev/null @@ -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 diff --git a/accounts/scripts/__init__.py b/accounts/scripts/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/accounts/scripts/bootstrap.py b/accounts/scripts/bootstrap.py deleted file mode 100755 index af31af1..0000000 --- a/accounts/scripts/bootstrap.py +++ /dev/null @@ -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") diff --git a/accounts/scripts/hash.py b/accounts/scripts/hash.py deleted file mode 100644 index 23e66ab..0000000 --- a/accounts/scripts/hash.py +++ /dev/null @@ -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) diff --git a/accounts/serverless.yml b/accounts/serverless.yml deleted file mode 100644 index 017bc31..0000000 --- a/accounts/serverless.yml +++ /dev/null @@ -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' diff --git a/accounts/tests/__init__.py b/accounts/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/accounts/tests/test_api.py b/accounts/tests/test_api.py deleted file mode 100644 index a9c8512..0000000 --- a/accounts/tests/test_api.py +++ /dev/null @@ -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") diff --git a/docker-compose.yml b/docker-compose.yml index 4234344..3a9ecaa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/package.json b/package.json index c0b426f..05f0297 100644 --- a/package.json +++ b/package.json @@ -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": {} }