mirror of
https://github.com/offen/website.git
synced 2024-11-22 09:00:28 +01:00
remove accounts app
This commit is contained in:
parent
45c9b104f5
commit
c9e40d8df5
@ -6,7 +6,6 @@ production_env: &production_env
|
|||||||
- SCRIPT_HOST=https://script-alpha.offen.dev
|
- SCRIPT_HOST=https://script-alpha.offen.dev
|
||||||
- AUDITORIUM_HOST=https://auditorium-alpha.offen.dev
|
- AUDITORIUM_HOST=https://auditorium-alpha.offen.dev
|
||||||
- VAULT_HOST=https://vault-alpha.offen.dev
|
- VAULT_HOST=https://vault-alpha.offen.dev
|
||||||
- ACCOUNTS_HOST=https://accounts-alpha.offen.dev
|
|
||||||
- HOMEPAGE_HOST=https://www.offen.dev
|
- HOMEPAGE_HOST=https://www.offen.dev
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- SECRET_ID_SERVER_CONNECTION_STRING=alpha/server/postgresConnectionString
|
- SECRET_ID_SERVER_CONNECTION_STRING=alpha/server/postgresConnectionString
|
||||||
@ -19,7 +18,6 @@ deploy_preconditions: &deploy_preconditions
|
|||||||
- auditorium
|
- auditorium
|
||||||
- packages
|
- packages
|
||||||
- shared
|
- shared
|
||||||
- accounts
|
|
||||||
filters:
|
filters:
|
||||||
branches:
|
branches:
|
||||||
only: /^master$/
|
only: /^master$/
|
||||||
@ -173,75 +171,6 @@ jobs:
|
|||||||
name: Run tests
|
name: Run tests
|
||||||
command: npm test
|
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:
|
deploy_golang:
|
||||||
docker:
|
docker:
|
||||||
- image: circleci/golang:1.12-node
|
- image: circleci/golang:1.12-node
|
||||||
@ -412,8 +341,6 @@ workflows:
|
|||||||
<<: *build_preconditions
|
<<: *build_preconditions
|
||||||
- shared:
|
- shared:
|
||||||
<<: *build_preconditions
|
<<: *build_preconditions
|
||||||
- accounts:
|
|
||||||
<<: *build_preconditions
|
|
||||||
- deploy_golang:
|
- deploy_golang:
|
||||||
<<: *deploy_preconditions
|
<<: *deploy_preconditions
|
||||||
- deploy_node:
|
- deploy_node:
|
||||||
|
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")
|
|
@ -31,7 +31,6 @@ services:
|
|||||||
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
|
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: Wsttdo4Z3mXV5sTc
|
||||||
EVENT_RETENTION_PERIOD: 4464h
|
EVENT_RETENTION_PERIOD: 4464h
|
||||||
@ -59,7 +58,6 @@ services:
|
|||||||
- KMS_HOST=http://localhost:8081
|
- KMS_HOST=http://localhost:8081
|
||||||
- SCRIPT_HOST=http://localhost:9977
|
- SCRIPT_HOST=http://localhost:9977
|
||||||
- AUDITORIUM_HOST=http://localhost:8080
|
- AUDITORIUM_HOST=http://localhost:8080
|
||||||
- ACCOUNTS_HOST=http://localhost:5000
|
|
||||||
- HOMEPAGE_HOST=http://localhost:8000
|
- HOMEPAGE_HOST=http://localhost:8000
|
||||||
|
|
||||||
script:
|
script:
|
||||||
|
@ -14,12 +14,9 @@
|
|||||||
"homepage": "https://github.com/offen/offen#readme",
|
"homepage": "https://github.com/offen/offen#readme",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"serverless": "^1.45.0",
|
"serverless": "^1.45.0",
|
||||||
"serverless-apigw-binary": "^0.4.4",
|
|
||||||
"serverless-domain-manager": "^2.6.13",
|
"serverless-domain-manager": "^2.6.13",
|
||||||
"serverless-finch": "^2.4.2",
|
"serverless-finch": "^2.4.2",
|
||||||
"serverless-pseudo-parameters": "^2.4.0",
|
"serverless-pseudo-parameters": "^2.4.0"
|
||||||
"serverless-python-requirements": "^4.3.0",
|
|
||||||
"serverless-wsgi": "^1.7.2"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {}
|
"devDependencies": {}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user