From d5be3feb7ed32fc727dc8524c49e2cb2ee32eece Mon Sep 17 00:00:00 2001 From: Frederik Ring Date: Wed, 10 Jul 2019 17:17:50 +0200 Subject: [PATCH] add accounts and user CRUD and connect to auth layer --- .circleci/config.yml | 16 ++++- Makefile | 1 + accounts/Makefile | 5 +- accounts/accounts/__init__.py | 20 +++++- accounts/accounts/api.py | 93 ++++++++++++++++++++++++ accounts/accounts/models.py | 39 ++++++++++ accounts/accounts/views.py | 132 ++++++++++++++++++++-------------- accounts/bootstrap.yml | 0 accounts/requirements-dev.txt | 1 + accounts/requirements.txt | 4 ++ accounts/scripts/__init__.py | 0 accounts/scripts/bootstrap.py | 23 ++++++ accounts/serverless.yml | 7 +- docker-compose.yml | 16 ++++- shared/http/jwt.go | 30 ++++++-- 15 files changed, 323 insertions(+), 64 deletions(-) create mode 100644 accounts/accounts/api.py create mode 100644 accounts/accounts/models.py create mode 100755 accounts/bootstrap.yml create mode 100644 accounts/scripts/__init__.py create mode 100755 accounts/scripts/bootstrap.py diff --git a/.circleci/config.yml b/.circleci/config.yml index 7a552ed..dc6ad49 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -196,6 +196,7 @@ jobs: docker: - image: circleci/python:3.6 environment: + POSTGRES_CONNECTION_STRING: postgres://circle:test@localhost:5432/circle_test?sslmode=disable HASHED_PASSWORD: JDJhJDEwJGpFRXJMOVVSQndZQlFQNjkxallkZi53aGp1cDMvRW5maGUvakZleG1pWFlnWEVXcU93ODBp JWT_PRIVATE_KEY: |- -----BEGIN PRIVATE KEY----- @@ -236,7 +237,10 @@ jobs: a3B4L0waKzP5QWcO865n1HCUTnV+s4lNcphBDZCrSwTkXnVnQWVPCL7ssoQyM0u3 HQIDAQAB -----END PUBLIC KEY----- - + - image: circleci/postgres:11.2-alpine + environment: + - POSTGRES_USER=circle + - POSTGRES_PASSWORD=test working_directory: ~/offen/accounts steps: - checkout: @@ -254,6 +258,16 @@ jobs: paths: - ~/offen/accounts/venv key: offen-accounts-{{ checksum "requirements.txt" }}-{{ checksum "requirements-dev.txt" }} + - run: + name: Waiting for Postgres to be ready + command: | + for i in `seq 1 10`; + do + nc -z localhost 5432 && echo Success && exit 0 + echo -n . + sleep 1 + done + echo Failed waiting for Postgres && exit 1 - run: name: Run tests command: | diff --git a/Makefile b/Makefile index c0ce3d1..a453fb0 100644 --- a/Makefile +++ b/Makefile @@ -20,5 +20,6 @@ setup: bootstrap: @docker-compose run kms make bootstrap @docker-compose run server make bootstrap + @docker-compose run accounts make bootstrap .PHONY: setup bootstrap diff --git a/accounts/Makefile b/accounts/Makefile index 5475c2b..f9efb2f 100644 --- a/accounts/Makefile +++ b/accounts/Makefile @@ -4,4 +4,7 @@ test: fmt: @black . -.PHONY: test fmt +bootstrap: + @python -m scripts.bootstrap + +.PHONY: test fmt bootstrap diff --git a/accounts/accounts/__init__.py b/accounts/accounts/__init__.py index cb325f6..2acff42 100644 --- a/accounts/accounts/__init__.py +++ b/accounts/accounts/__init__.py @@ -1,5 +1,23 @@ +from os import environ + from flask import Flask +from flask_sqlalchemy import SQLAlchemy +from flask_admin import Admin app = Flask(__name__) +app.secret_key = environ.get("SESSION_SECRET") +app.config["SQLALCHEMY_DATABASE_URI"] = environ.get("POSTGRES_CONNECTION_STRING") +db = SQLAlchemy(app) -import accounts.views +from accounts.models import Account, User +from accounts.views import AccountView, UserView +import accounts.api + +# set optional bootswatch theme +app.config["FLASK_ADMIN_SWATCH"] = "flatly" + +admin = Admin(app, name="offen admin", template_mode="bootstrap3") +# Add administrative views here + +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 new file mode 100644 index 0000000..5591a64 --- /dev/null +++ b/accounts/accounts/api.py @@ -0,0 +1,93 @@ +from datetime import datetime, timedelta +from os import environ +import base64 +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 + + +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=[environ.get("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("auth", "", expires=0) + resp.status_code = 401 + return resp + + private_key = environ.get("JWT_PRIVATE_KEY", "") + expiry = datetime.utcnow() + timedelta(hours=24) + encoded = jwt.encode( + {"ok": True, "exp": expiry}, private_key.encode(), algorithm="RS256" + ).decode("utf-8") + + resp = make_response(jsonify({"match": match})) + resp.set_cookie( + "auth", + encoded, + httponly=True, + expires=expiry, + path="/", + domain=environ.get("COOKIE_DOMAIN"), + samesite="strict", + ) + return resp + + +@app.route("/api/login", methods=["GET"]) +@cross_origin(origins=[environ.get("CORS_ORIGIN", "*")], supports_credentials=True) +@json_error +def get_login(): + auth_cookie = request.cookies.get("auth") + public_key = environ.get("JWT_PUBLIC_KEY", "") + try: + jwt.decode(auth_cookie, public_key) + except jwt.exceptions.PyJWTError as unauthorized_error: + return jsonify({"error": str(unauthorized_error), "status": 401}), 401 + + return jsonify({"ok": True}) + + +# This route is not supposed to be called by client-side applications, so +# no CORS configuration is added +@app.route("/api/key", methods=["GET"]) +@json_error +def key(): + public_key = environ.get("JWT_PUBLIC_KEY", "").strip() + return jsonify({"key": public_key}) diff --git a/accounts/accounts/models.py b/accounts/accounts/models.py new file mode 100644 index 0000000..b4fd6c3 --- /dev/null +++ b/accounts/accounts/models.py @@ -0,0 +1,39 @@ +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, primary_key=True, default=generate_key) + name = db.Column(db.String, nullable=False, unique=True) + users = db.relationship("AccountUserAssociation", back_populates="account") + + def __repr__(self): + return self.name + + +class User(db.Model): + __tablename__ = "users" + user_id = db.Column(db.String, primary_key=True, default=generate_key) + email = db.Column(db.String, nullable=False, unique=True) + hashed_password = db.Column(db.String, nullable=False) + accounts = db.relationship("AccountUserAssociation", back_populates="user") + + +class AccountUserAssociation(db.Model): + __tablename__ = "account_to_user" + + id = db.Column(db.Integer, primary_key=True) + + user_id = db.Column(db.String, db.ForeignKey("users.user_id"), nullable=False) + account_id = db.Column( + db.String, 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/views.py b/accounts/accounts/views.py index e1aad76..3071006 100644 --- a/accounts/accounts/views.py +++ b/accounts/accounts/views.py @@ -1,69 +1,97 @@ from datetime import datetime, timedelta from os import environ -import base64 -from flask import jsonify, render_template, make_response, request -from flask_cors import cross_origin +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 app - -@app.route("/") -def home(): - return render_template("index.html") +from accounts import db +from accounts.models import AccountUserAssociation -@app.route("/api/login", methods=["POST"]) -@cross_origin(origins=[environ.get("CORS_ORIGIN", "*")], supports_credentials=True) -def post_login(): - credentials = request.get_json(force=True) +class RemoteServerException(Exception): + status = 0 - if credentials["username"] != environ.get("USER", "offen"): - return jsonify({"error": "bad username", "status": 401}), 401 - - hashed_password = base64.standard_b64decode(environ.get("HASHED_PASSWORD", "")) - if not bcrypt.verify(credentials["password"], hashed_password): - return jsonify({"error": "bad password", "status": 401}), 401 +def create_remote_account(name, account_id): private_key = environ.get("JWT_PRIVATE_KEY", "") - expiry = datetime.utcnow() + timedelta(hours=24) - try: - encoded = jwt.encode( - {"ok": True, "exp": expiry}, private_key.encode(), algorithm="RS256" - ).decode("utf-8") - except jwt.exceptions.PyJWTError as encode_error: - return jsonify({"error": str(encode_error), "status": 500}), 500 + expiry = datetime.utcnow() + timedelta(seconds=10) + encoded = jwt.encode( + {"ok": True, "exp": expiry, "priv": {"rpc": "1"}}, + private_key.encode(), + algorithm="RS256", + ).decode("utf-8") - resp = make_response(jsonify({"ok": True})) - resp.set_cookie( - "auth", - encoded, - httponly=True, - expires=expiry, - path="/", - domain=environ.get("COOKIE_DOMAIN"), - samesite="strict" + r = requests.post( + "{}/accounts".format(environ.get("SERVER_HOST")), + json={"name": name, "account_id": account_id}, + headers={"X-RPC-Authentication": encoded}, ) - return resp + + if r.status_code > 299: + err = r.json() + remote_err = RemoteServerException(err["error"]) + remote_err.status = err["status"] + raise remote_err -@app.route("/api/login", methods=["GET"]) -@cross_origin(origins=[environ.get("CORS_ORIGIN", "*")], supports_credentials=True) -def get_login(): - auth_cookie = request.cookies.get("auth") - public_key = environ.get("JWT_PUBLIC_KEY", "") - try: - jwt.decode(auth_cookie, public_key) - except jwt.exceptions.PyJWTError as unauthorized_error: - return jsonify({"error": str(unauthorized_error), "status": 401}), 401 - - return jsonify({"ok": True}) +class AccountForm(Form): + name = StringField( + "Account Name", + validators=[InputRequired()], + description="This is the account name visible to users", + ) -# This route is not supposed to be called by client-side applications, so -# no CORS configuration is added -@app.route("/api/key", methods=["GET"]) -def key(): - public_key = environ.get("JWT_PUBLIC_KEY", "").strip() - return jsonify({"key": public_key}) +class AccountView(ModelView): + form = AccountForm + column_display_all_relations = True + column_list = ("account_id", "name") + + def after_model_change(self, form, model, is_created): + if is_created: + try: + create_remote_account(model.name, model.account_id) + except RemoteServerException as server_error: + db.session.delete(model) + db.session.commit() + raise server_error + + +class UserView(ModelView): + inline_models = [(AccountUserAssociation, dict(form_columns=["id", "account"]))] + column_auto_select_related = True + column_display_all_relations = True + column_list = ("user_id", "email") + form_columns = ("email", "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().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().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 new file mode 100755 index 0000000..e69de29 diff --git a/accounts/requirements-dev.txt b/accounts/requirements-dev.txt index 9bb2a15..bcfacb5 100644 --- a/accounts/requirements-dev.txt +++ b/accounts/requirements-dev.txt @@ -1,2 +1,3 @@ pytest black +pyyaml diff --git a/accounts/requirements.txt b/accounts/requirements.txt index 3182c5b..710ef6d 100644 --- a/accounts/requirements.txt +++ b/accounts/requirements.txt @@ -1,6 +1,10 @@ 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 +psycopg2==2.8.3 +requests==2.22.0 diff --git a/accounts/scripts/__init__.py b/accounts/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/accounts/scripts/bootstrap.py b/accounts/scripts/bootstrap.py new file mode 100755 index 0000000..2fd87e7 --- /dev/null +++ b/accounts/scripts/bootstrap.py @@ -0,0 +1,23 @@ +import yaml +from passlib.hash import bcrypt + +from accounts import db +from accounts.models import Account + +if __name__ == "__main__": + 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) + + db.session.commit() + + print("Successfully bootstrapped accounts database") diff --git a/accounts/serverless.yml b/accounts/serverless.yml index 2f1ff22..a81ad85 100644 --- a/accounts/serverless.yml +++ b/accounts/serverless.yml @@ -26,6 +26,10 @@ custom: production: vault.offen.dev staging: vault-staging.offen.dev alpha: vault-alpha.offen.dev + serverHost: + production: server.offen.dev + staging: server-staging.offen.dev + alpha: server-alpha.offen.dev domain: production: accounts.offen.dev staging: accounts-staging.offen.dev @@ -60,9 +64,10 @@ functions: path: '{proxy+}' method: any environment: - USER: offen CORS_ORIGIN: https://${self:custom.origin.${self:custom.stage}} COOKIE_DOMAIN: ${self:custom.cookieDomain.${self:custom.stage}} 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}' HASHED_PASSWORD: ${ssm:/aws/reference/secretsmanager/${self:custom.stage}/accounts/hashedBasicAuthPassword~true} + SESSION_SECRET: '${ssm:/aws/reference/secretsmanager/${self:custom.stage}/accounts/sessionSecret~true}' + SERVER_URL: ${self:custom.serverHost.${self:custom.stage}} diff --git a/docker-compose.yml b/docker-compose.yml index 53a9583..1f414cd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,6 +24,11 @@ services: environment: POSTGRES_PASSWORD: develop + accounts_database: + image: postgres:11.2 + environment: + POSTGRES_PASSWORD: develop + server: build: context: '.' @@ -31,6 +36,7 @@ services: working_dir: /offen/server volumes: - .:/offen + - ./bootstrap.yml:/offen/server/bootstrap.yml - serverdeps:/go/pkg/mod environment: POSTGRES_CONNECTION_STRING: postgres://postgres:develop@server_database:5432/postgres?sslmode=disable @@ -100,16 +106,20 @@ services: working_dir: /offen/accounts volumes: - .:/offen + - ./bootstrap.yml:/offen/accounts/bootstrap.yml - accountdeps:/root/.local command: flask run --host 0.0.0.0 ports: - 5000:5000 + links: + - accounts_database environment: - FLASK_APP: accounts + FLASK_APP: accounts:app FLASK_ENV: development + POSTGRES_CONNECTION_STRING: postgres://postgres:develop@accounts_database:5432/postgres?sslmode=disable CORS_ORIGIN: http://localhost:9977 - # local password is `develop` - HASHED_PASSWORD: JDJhJDEwJGpFRXJMOVVSQndZQlFQNjkxallkZi53aGp1cDMvRW5maGUvakZleG1pWFlnWEVXcU93ODBp + SERVER_HOST: http://server:8080 + SESSION_SECRET: vndJRFJTiyjfgtTF JWT_PRIVATE_KEY: |- -----BEGIN PRIVATE KEY----- MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCa6AEl0RUW43YS diff --git a/shared/http/jwt.go b/shared/http/jwt.go index 686d682..633a343 100644 --- a/shared/http/jwt.go +++ b/shared/http/jwt.go @@ -26,9 +26,18 @@ const ClaimsContextKey contextKey = "claims" func JWTProtect(keyURL, cookieName string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - authCookie, err := r.Cookie(cookieName) - if err != nil { - RespondWithJSONError(w, fmt.Errorf("jwt: error reading cookie: %s", err), http.StatusForbidden) + var jwtValue string + var isRPC bool + if authCookie, err := r.Cookie(cookieName); err == nil { + jwtValue = authCookie.Value + } else { + if header := r.Header.Get("X-RPC-Authentication"); header != "" { + jwtValue = header + isRPC = true + } + } + if jwtValue == "" { + RespondWithJSONError(w, errors.New("jwt: could not infer JWT value from cookie or header"), http.StatusForbidden) return } @@ -56,7 +65,7 @@ func JWTProtect(keyURL, cookieName string) func(http.Handler) http.Handler { return } - token, jwtErr := jwt.ParseVerify(strings.NewReader(authCookie.Value), jwa.RS256, pubKey) + token, jwtErr := jwt.ParseVerify(strings.NewReader(jwtValue), jwa.RS256, pubKey) if jwtErr != nil { RespondWithJSONError(w, fmt.Errorf("jwt: error parsing token: %v", jwtErr), http.StatusForbidden) return @@ -68,8 +77,19 @@ func JWTProtect(keyURL, cookieName string) func(http.Handler) http.Handler { } privateClaims, _ := token.Get("priv") - r = r.WithContext(context.WithValue(r.Context(), ClaimsContextKey, privateClaims)) + if isRPC { + cast, ok := privateClaims.(map[string]interface{}) + if !ok { + RespondWithJSONError(w, fmt.Errorf("jwt: malformed private claims section in token: %v", privateClaims), http.StatusBadRequest) + return + } + if cast["rpc"] != "1" { + RespondWithJSONError(w, errors.New("jwt: token claims do not allow the requested operation"), http.StatusForbidden) + return + } + } + r = r.WithContext(context.WithValue(r.Context(), ClaimsContextKey, privateClaims)) next.ServeHTTP(w, r) }) }