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

increase test coverage for vault and login api

This commit is contained in:
Frederik Ring 2019-07-16 09:39:31 +02:00
parent ad2a574338
commit 1d07355112
7 changed files with 185 additions and 41 deletions

2
.gitignore vendored
View File

@ -5,3 +5,5 @@ package-lock.json
# mkcert certificates # mkcert certificates
*.pem *.pem
venv/ venv/
bootstrap-alpha.yml

View File

@ -1,6 +1,5 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
from os import environ from os import environ
import base64
from functools import wraps from functools import wraps
from flask import jsonify, make_response, request from flask import jsonify, make_response, request
@ -11,6 +10,8 @@ import jwt
from accounts import app from accounts import app
from accounts.models import User from accounts.models import User
COOKIE_KEY = "auth"
def json_error(handler): def json_error(handler):
@wraps(handler) @wraps(handler)
@ -48,7 +49,7 @@ def post_login():
raise UnauthorizedError("bad password") raise UnauthorizedError("bad password")
except UnauthorizedError as unauthorized_error: except UnauthorizedError as unauthorized_error:
resp = make_response(jsonify({"error": str(unauthorized_error), "status": 401})) resp = make_response(jsonify({"error": str(unauthorized_error), "status": 401}))
resp.set_cookie("auth", "", expires=0) resp.set_cookie(COOKIE_KEY, "", expires=0)
resp.status_code = 401 resp.status_code = 401
return resp return resp
@ -56,7 +57,6 @@ def post_login():
expiry = datetime.utcnow() + timedelta(hours=24) expiry = datetime.utcnow() + timedelta(hours=24)
encoded = jwt.encode( encoded = jwt.encode(
{ {
"ok": True,
"exp": expiry, "exp": expiry,
"priv": { "priv": {
"userId": match.user_id, "userId": match.user_id,
@ -69,7 +69,7 @@ def post_login():
resp = make_response(jsonify({"user": match.serialize()})) resp = make_response(jsonify({"user": match.serialize()}))
resp.set_cookie( resp.set_cookie(
"auth", COOKIE_KEY,
encoded, encoded,
httponly=True, httponly=True,
expires=expiry, expires=expiry,
@ -84,7 +84,7 @@ def post_login():
@cross_origin(origins=[environ.get("CORS_ORIGIN", "*")], supports_credentials=True) @cross_origin(origins=[environ.get("CORS_ORIGIN", "*")], supports_credentials=True)
@json_error @json_error
def get_login(): def get_login():
auth_cookie = request.cookies.get("auth") auth_cookie = request.cookies.get(COOKIE_KEY)
public_key = environ.get("JWT_PUBLIC_KEY", "") public_key = environ.get("JWT_PUBLIC_KEY", "")
try: try:
token = jwt.decode(auth_cookie, public_key) token = jwt.decode(auth_cookie, public_key)
@ -103,10 +103,22 @@ def get_login():
return jsonify({"user": match.serialize()}) return jsonify({"user": match.serialize()})
# This route is not supposed to be called by client-side applications, so @app.route("/api/logout", methods=["POST"])
# no CORS configuration is added @cross_origin(origins=[environ.get("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"]) @app.route("/api/key", methods=["GET"])
@json_error @json_error
def key(): def key():
"""
This route is not supposed to be called by client-side applications, so
no CORS configuration is added
"""
public_key = environ.get("JWT_PUBLIC_KEY", "").strip() public_key = environ.get("JWT_PUBLIC_KEY", "").strip()
return jsonify({"key": public_key}) return jsonify({"key": public_key})

View File

@ -67,13 +67,15 @@ class UserView(ModelView):
column_display_all_relations = True column_display_all_relations = True
column_list = ("email", "user_id") column_list = ("email", "user_id")
form_columns = ("email", "accounts") 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): def on_model_change(self, form, model, is_created):
if form.password.data: if form.password.data:
model.hashed_password = bcrypt.hash(form.password.data) model.hashed_password = bcrypt.hash(form.password.data)
def get_create_form(self): def get_create_form(self):
form = super().get_create_form() form = super(UserView, self).get_create_form()
form.password = PasswordField( form.password = PasswordField(
"Password", "Password",
validators=[ validators=[
@ -85,13 +87,11 @@ class UserView(ModelView):
return form return form
def get_edit_form(self): def get_edit_form(self):
form = super().get_edit_form() form = super(UserView, self).get_edit_form()
form.password = PasswordField( form.password = PasswordField(
"Password", "Password",
description="When left blank, the password will remain unchanged on update", description="When left blank, the password will remain unchanged on update",
validators=[ validators=[EqualTo("confirm", message="Passwords must match")],
EqualTo("confirm", message="Passwords must match"),
],
) )
form.confirm = PasswordField("Repeat Password", validators=[]) form.confirm = PasswordField("Repeat Password", validators=[])
return form return form

View File

@ -17,6 +17,7 @@ def build_api_arn(method_arn):
aws_region, aws_account_id, rest_api_id, stage aws_region, aws_account_id, rest_api_id, stage
) )
def build_response(api_arn, allow): def build_response(api_arn, allow):
effect = "Deny" effect = "Deny"
if allow: if allow:
@ -30,17 +31,18 @@ def build_response(api_arn, allow):
{ {
"Action": ["execute-api:Invoke"], "Action": ["execute-api:Invoke"],
"Effect": effect, "Effect": effect,
"Resource": [api_arn] "Resource": [api_arn],
} }
] ],
} },
} }
def handler(event, context): def handler(event, context):
api_arn = build_api_arn(event["methodArn"]) api_arn = build_api_arn(event["methodArn"])
auth_string = base64.standard_b64decode(event["authorizationToken"].lstrip("Basic ")).decode() encoded_auth = event["authorizationToken"].lstrip("Basic ")
auth_string = base64.standard_b64decode(encoded_auth).decode()
if not auth_string: if not auth_string:
return build_response(api_arn, False) return build_response(api_arn, False)
@ -51,7 +53,8 @@ def handler(event, context):
if user != environ.get("BASIC_AUTH_USER"): if user != environ.get("BASIC_AUTH_USER"):
return build_response(api_arn, False) return build_response(api_arn, False)
hashed_password = base64.standard_b64decode(environ.get("HASHED_BASIC_AUTH_PASSWORD")).decode() encoded_password = environ.get("HASHED_BASIC_AUTH_PASSWORD")
hashed_password = base64.standard_b64decode(encoded_password).decode()
if not bcrypt.verify(password, hashed_password): if not bcrypt.verify(password, hashed_password):
return build_response(api_arn, False) return build_response(api_arn, False)

View File

@ -85,13 +85,13 @@ functions:
environment: environment:
CORS_ORIGIN: https://${self:custom.origin.${self:custom.stage}} CORS_ORIGIN: https://${self:custom.origin.${self:custom.stage}}
COOKIE_DOMAIN: ${self:custom.cookieDomain.${self:custom.stage}} COOKIE_DOMAIN: ${self:custom.cookieDomain.${self:custom.stage}}
SERVER_URL: ${self:custom.serverHost.${self:custom.stage}}
JWT_PRIVATE_KEY: '${ssm:/aws/reference/secretsmanager/${self:custom.stage}/accounts/jwtPrivateKey~true}' JWT_PRIVATE_KEY: '${ssm:/aws/reference/secretsmanager/${self:custom.stage}/accounts/jwtPrivateKey~true}'
JWT_PUBLIC_KEY: '${ssm:/aws/reference/secretsmanager/${self:custom.stage}/accounts/jwtPublicKey~true}' JWT_PUBLIC_KEY: '${ssm:/aws/reference/secretsmanager/${self:custom.stage}/accounts/jwtPublicKey~true}'
HASHED_BASIC_AUTH_PASSWORD: ${ssm:/aws/reference/secretsmanager/${self:custom.stage}/accounts/hashedBasicAuthPassword~true}
BASIC_AUTH_USER: ${ssm:/aws/reference/secretsmanager/${self:custom.stage}/accounts/basicAuthUser~true} BASIC_AUTH_USER: ${ssm:/aws/reference/secretsmanager/${self:custom.stage}/accounts/basicAuthUser~true}
HASHED_BASIC_AUTH_PASSWORD: ${ssm:/aws/reference/secretsmanager/${self:custom.stage}/accounts/hashedBasicAuthPassword~true}
SESSION_SECRET: '${ssm:/aws/reference/secretsmanager/${self:custom.stage}/accounts/sessionSecret~true}' SESSION_SECRET: '${ssm:/aws/reference/secretsmanager/${self:custom.stage}/accounts/sessionSecret~true}'
SERVER_URL: ${self:custom.serverHost.${self:custom.stage}} MYSQL_CONNECTION_STRING: '${ssm:/aws/reference/secretsmanager/${self:custom.stage}/accounts/mysqlConnectionString~true}'
POSTGRES_CONNECTION_STRING: '${ssm:/aws/reference/secretsmanager/${self:custom.stage}/accounts/postgresConnectionString~true}'
resources: resources:
Resources: Resources:

148
accounts/tests/test_api.py Normal file
View File

@ -0,0 +1,148 @@
import unittest
import json
import base64
from json import loads
from time import time
from os import environ
from accounts import app
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 = loads(rv.data)
assert data["key"] == environ.get("JWT_PUBLIC_KEY")
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
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 = 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")

View File

@ -1,21 +0,0 @@
import unittest
import json
from accounts import app
class TestJWT(unittest.TestCase):
def setUp(self):
self.app = app.test_client()
def test_jwt_flow(self):
rv = self.app.get("/api/login")
assert rv.status.startswith("401")
rv = self.app.post(
"/api/login", data=json.dumps({"username": "develop@offen.dev", "password": "develop"})
)
assert rv.status.startswith("200")
rv = self.app.get("/api/login")
assert rv.status.startswith("200")