From 7f77d693ed889795c12190e69f02187470b32655 Mon Sep 17 00:00:00 2001 From: Frederik Ring Date: Sun, 7 Jul 2019 13:21:20 +0200 Subject: [PATCH] move authentication to jwt based token system handled by account app --- .circleci/config.yml | 48 +++++++++++++++ .editorconfig | 4 ++ accounts/.gitignore | 4 +- accounts/accounts/__init__.py | 62 +++++++++++++++++++- accounts/accounts/tests/test_jwt.py | 21 +++++++ accounts/accounts/tests/test_status.py | 13 ----- accounts/requirements.txt | 4 ++ accounts/serverless.yml | 10 ++++ docker-compose.yml | 63 +++++++++++++++++--- shared/http/jwt.go | 81 ++++++++++++++++++++++++++ 10 files changed, 283 insertions(+), 27 deletions(-) create mode 100644 accounts/accounts/tests/test_jwt.py delete mode 100644 accounts/accounts/tests/test_status.py create mode 100644 shared/http/jwt.go diff --git a/.circleci/config.yml b/.circleci/config.yml index 98b06c2..92d9861 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -94,6 +94,9 @@ jobs: steps: - checkout: path: ~/offen + - run: + name: Install dependencies + command: go get ./... - run: name: Run tests command: make test @@ -150,6 +153,9 @@ jobs: path: ~/offen - restore_cache: key: offen-auditorium-{{ checksum "package.json" }} + - run: + name: Install lsof + command: sudo apt-get install lsof - run: name: Install dependencies command: npm install @@ -187,6 +193,48 @@ jobs: accounts: docker: - image: circleci/python:3.6 + environment: + HASHED_PASSWORD: JDJhJDEwJGpFRXJMOVVSQndZQlFQNjkxallkZi53aGp1cDMvRW5maGUvakZleG1pWFlnWEVXcU93ODBp + JWT_PRIVATE_KEY: |- + -----BEGIN PRIVATE KEY----- + MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCzgU18PnRrpbVK + LU4EewU476arjLeMAXoxQAvrufvnwAGlrvnuh+TE7z7R3KslOyP0m3bTMaNyn2la + DFz0ERR8KFA3rbUUDLG8QAUomm6X2WNbZJqFG/ORBWyEWy/bvNEbCTtF8K09v6HT + PcNzpSrzN8MK/xsIXnMTa/acftbfD3jnb1KFNWaSUKgEM9OL8NSW8PpqTf+le/+R + 0Dwhd4vNnELpobKY3tvhxjvlQmbZA6vRrOoWfWbKXuS/7B7CWXWEhXPTbcJPcKPt + zlkpJGlrcHgvTBorM/lBZw7zrmfUcJROdX6ziU1ymEENkKtLBORedWdBZU8Ivuyy + hDIzS7cdAgMBAAECggEAVReXfq0wjRMJhHdDg5Y5nIrmbG4RWFoe7ZfZzs3kXzDC + 1yLCMdPTm5N6KQu9SbHmUn8b7fOa8qwkyd4QdlZeapjFpg8/RpjZ7E5A48WJZYxU + sC9ZnH3qkTWMApYjcrvoODPBGF+GED52XOfrbje+y3sEh4L08purW2qThg4Ol8A2 + +lqh7W0DCAYV9BG4jFo0QyJRsXa88JIxVS2gSbuegDyHwRTtPq0ZH+vqUEsJti0c + G60hwcl0v9eRbJk/e9lzhKFKnf4/ReRAX9pUyyIb7za3vhYwDmjglgZ+Ax4HhkPK + SR8KjqX5hQ2nOtDmxkxEO1QF5Qm99VLlZyo55vdJYQKBgQDomtQ7jwc41JM9naZC + 1agGjJamZAtfDf/00KQILWplaDCDEyue9A1iSnu7yNLX4VOjsKhfvugARwz2pOrw + PkOYasurM+P16qNkyfyVUilC/QoOcm5UjsCy/wAViwYFG8hZGBj+jPELqPglSdr4 + m2FPTFOmtQDzB7EOk9Mzaic26QKBgQDFjz96/3OAC2dBdjQz7zXKHYPPo2gXqS76 + 3UOX2u+S43jJGEEKLdWa34qO6KrnnUsivyvEyF2/5H6n8Dc2Xj0LBdO67wyzbtFM + dI9RiQ3DqTBbebmrkSdIaTBAGu1VtSCzBKUqckkM3lLSHhJBg0XrURGdqro28nhZ + uSQ8EzzGFQKBgQCCiVlvrz3jU9Dp9E45FcR9IGrvKBgFmUq6bliPykT6cfU/qgOB + 6f6U2a4E3ZgN1QNmSp7DVNTISxdoV3cNqjOvFsgD5VQaTzqxNnXMqtZDJNR+9RMb + 2x0jlt3KOUIAne3aqh5kxF4GKCZSbtc3S6PZp8EOPmgw+3EO+EC/iuRE+QKBgHIW + uqs2UKY2b6ffMmB3mVGiX9eOX3OikW3wT7OnjMkAMmW3awAM3hl1VNgYx3HAZX6o + dgdLStChjP9A+zGblJcEA3Ulzejla1tCyO1mP5up3jJFhpLs3Ym0rVen9T2Uv1CC + sztjCoqy7ZNIKHTK8Zrmk0zBJo7K0fPGtoU2+tbNAoGAc2x7cW8y9ERRJg8FSWj0 + ADPUPzsW3Bu76U0oVEXg66d3+D9z8LBVhBYjL/+tJWADapePnye+gpqzd4kGFeV4 + q49Qz5hhZUzsUl2iOURQLUnI2g0AINiMIVL5EkZtdD+Au65+AaYL7YEsXmKcLXHX + coEomAoy102k4A6WsM2bKf4= + -----END PRIVATE KEY----- + JWT_PUBLIC_KEY: |- + -----BEGIN PUBLIC KEY----- + MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAs4FNfD50a6W1Si1OBHsF + OO+mq4y3jAF6MUAL67n758ABpa757ofkxO8+0dyrJTsj9Jt20zGjcp9pWgxc9BEU + fChQN621FAyxvEAFKJpul9ljW2SahRvzkQVshFsv27zRGwk7RfCtPb+h0z3Dc6Uq + 8zfDCv8bCF5zE2v2nH7W3w94529ShTVmklCoBDPTi/DUlvD6ak3/pXv/kdA8IXeL + zZxC6aGymN7b4cY75UJm2QOr0azqFn1myl7kv+wewll1hIVz023CT3Cj7c5ZKSRp + a3B4L0waKzP5QWcO865n1HCUTnV+s4lNcphBDZCrSwTkXnVnQWVPCL7ssoQyM0u3 + HQIDAQAB + -----END PUBLIC KEY----- + working_directory: ~/offen/accounts steps: - checkout: diff --git a/.editorconfig b/.editorconfig index 9fc134f..f382e13 100644 --- a/.editorconfig +++ b/.editorconfig @@ -13,3 +13,7 @@ indent_style = tab [{*.js,*.yml,*.md,Gopkg.toml,package.json,*.html,Dockerfile.*}] indent_style = space indent_size = 2 + +[*.py] +indent_style = space +indent_size = 4 diff --git a/accounts/.gitignore b/accounts/.gitignore index 37b9c3f..f5bd592 100644 --- a/accounts/.gitignore +++ b/accounts/.gitignore @@ -1,3 +1,4 @@ +*.pem *.dot __pycache__ *.log @@ -5,6 +6,3 @@ __pycache__ .pytest_cache venv/ *.pyc - -models/ - diff --git a/accounts/accounts/__init__.py b/accounts/accounts/__init__.py index 90d5781..dee5a29 100644 --- a/accounts/accounts/__init__.py +++ b/accounts/accounts/__init__.py @@ -1,4 +1,11 @@ -from flask import Flask, jsonify, render_template +from datetime import datetime, timedelta +from os import environ +import base64 + +from flask import Flask, jsonify, render_template, make_response, request +from flask_cors import cross_origin +from passlib.hash import bcrypt +import jwt app = Flask(__name__) @@ -8,6 +15,55 @@ def home(): return render_template("index.html") -@app.route("/status") -def status(): +@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) + + 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 + + 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 + + resp = make_response(jsonify({"ok": True})) + resp.set_cookie( + "auth", + encoded, + httponly=True, + expires=expiry, + path="/", + domain=environ.get("COOKIE_DOMAIN"), + ) + return resp + + +@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}) + + +# 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}) diff --git a/accounts/accounts/tests/test_jwt.py b/accounts/accounts/tests/test_jwt.py new file mode 100644 index 0000000..2b0d423 --- /dev/null +++ b/accounts/accounts/tests/test_jwt.py @@ -0,0 +1,21 @@ +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": "offen", "password": "develop"}) + ) + assert rv.status.startswith("200") + + rv = self.app.get("/api/login") + assert rv.status.startswith("200") diff --git a/accounts/accounts/tests/test_status.py b/accounts/accounts/tests/test_status.py deleted file mode 100644 index 1589a6d..0000000 --- a/accounts/accounts/tests/test_status.py +++ /dev/null @@ -1,13 +0,0 @@ -import unittest - -from accounts import app - - -class TestStatus(unittest.TestCase): - def setUp(self): - self.app = app.test_client() - - def test_get(self): - rv = self.app.get("/status") - assert rv.status.startswith("200") - assert b"ok" in rv.data diff --git a/accounts/requirements.txt b/accounts/requirements.txt index 0a04170..3182c5b 100644 --- a/accounts/requirements.txt +++ b/accounts/requirements.txt @@ -1,2 +1,6 @@ Flask==1.0.2 +Flask-Cors==3.0.8 werkzeug==0.15.4 +pyjwt[crypto]==1.7.1 +passlib==1.7.1 +bcrypt==3.1.7 diff --git a/accounts/serverless.yml b/accounts/serverless.yml index e04f2ff..87839e6 100644 --- a/accounts/serverless.yml +++ b/accounts/serverless.yml @@ -25,6 +25,10 @@ plugins: custom: stage: ${opt:stage, self:provider.stage} + origin: + production: vault.offen.dev + staging: vault-staging.offen.dev + alpha: vault-alpha.offen.dev domain: production: accounts.offen.dev staging: accounts-staging.offen.dev @@ -57,3 +61,9 @@ functions: - http: path: '{proxy+}' method: any + environment: + USER: offen + CORS_ORIGIN: https://${self:custom.origin.${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/hashedPassword~true} diff --git a/docker-compose.yml b/docker-compose.yml index 420db72..b715a6a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,16 +10,19 @@ services: - .:/offen - kmsdeps:/go/pkg/mod environment: - - KEY_FILE=key.txt - - PORT=8081 + KEY_FILE: key.txt + PORT: 8081 + JWT_PUBLIC_KEY: http://accounts:5000/api/key ports: - 8081:8081 command: refresh run + links: + - accounts server_database: image: postgres:11.2 environment: - - POSTGRES_PASSWORD=develop + POSTGRES_PASSWORD: develop server: build: @@ -30,9 +33,10 @@ services: - .:/offen - serverdeps:/go/pkg/mod environment: - - POSTGRES_CONNECTION_STRING=postgres://postgres:develop@server_database:5432/postgres?sslmode=disable - - KMS_ENCRYPTION_ENDPOINT=http://kms:8081/encrypt - - PORT=8080 + 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 ports: - 8080:8080 command: refresh run @@ -57,6 +61,7 @@ services: - KMS_HOST=http://localhost:8081 - SCRIPT_HOST=http://localhost:9977 - AUDITORIUM_HOST=http://localhost:9955 + - ACCOUNTS_HOST=http://localhost:5000 script: build: @@ -98,8 +103,50 @@ services: ports: - 5000:5000 environment: - - FLASK_APP=accounts - - FLASK_ENV=development + FLASK_APP: accounts + FLASK_ENV: development + CORS_ORIGIN: http://localhost:9977 + # local password is `develop` + HASHED_PASSWORD: JDJhJDEwJGpFRXJMOVVSQndZQlFQNjkxallkZi53aGp1cDMvRW5maGUvakZleG1pWFlnWEVXcU93ODBp + JWT_PRIVATE_KEY: |- + -----BEGIN PRIVATE KEY----- + MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCa6AEl0RUW43YS + 6cfbYkEDoSxQV8WoEQPuwM9OPdrJjTHYwX9+J9dPhvdnuSIGca86jgYwg45xG5MC + 7arOIIGOI8rBuXWyPF/iZliWxUmzEMIsmZYFNTjezRl95ymy43ZH9JCS2pw2gTTq + Z4ln/hPkl2KMs+Two7iux52sgFRAxWYPNgqCpkbzEAL+zCHIPaZqsZjRX/nES7/y + JZWyvSSDQ/CsdeR+xssx2F1JXjQHl68dBEJJc7hAU4xNqs9aP5vaaL6PbwWOh6S7 + bzieDvankyZSrCxVBbMy8+JHXDLLQTp8Lv3y38HL/Ez0gefm2kRnGNTU19kwoFT+ + ceeuYE6RAgMBAAECggEAcZ2g2d/UnAkRXSXi1GHoVYUtP3BhJLf2LnN0mWp8wj+x + Q84IeLs4DLhtVcJP1nIjl8r7dzHGk+cpmIhBMxZcb6iI2jXwwV3O5fszFsJ1H8U2 + 5gdwJTm4EJJWFCYsS2zSIEycjVmSIdf6u8Jc4c1VQeBXA+QeEvHCT09RsmgdY7NA + zEZtpIf4EUXtQMmZdKwYVRJUJlBccAP2IR23lhAV6pS5n0pwOodnZD7lf/XAIrOm + Shy0WE+8DAxmg1DXzFLC+vw45kGJTdhHKc+ZFj86Nv2nUx0u12mb1hF3XlC66ojc + jmS0i5NbJVCqTSSXlC81V/16wCJmWIF0+O6y2EkZwQKBgQDLcgORV1QhUAz6QfiP + wR9OIWCJyxaGbaBe1FZvx8T9ofri8z/ycwxkzlEDCLYu7CZ2ZCpZLMwE5njzfmTq + ixhiVvu/fCPgo4bXRt6H/BaggBs04p8UB3ZJKl0ib67kKMXQjH2qFhuCS2Jpy3ru + REmRoukY4iAqHRdIrP6zW0y55QKBgQDC7BDIZrSbZNwkdbj+UilKEit+plBAvdan + 7CGLg4XhwiW7XYr9gHM3RKCAlUFRIsAaFClxiCvy21p3UpbO0XC5aIC5ReWYK9zh + THqwjxgDCev4FIe0md2fQCP7TP4wTYwvKjSqzI4v/XW86VYynegDBQSqeI8heuY4 + YbTBYMvHPQKBgQCy89wdiVJwZwizTTpFwNs3j3ZqXmC26FEreN17P56Qd13HKa6z + Je3d8fkikRQnnAONGjiB7jybhtsXW7OK98UAI4EYAytP2qeuTyFJPj3s+iJ0V28U + YCf03bXEp7aP7Slrc1jKNt4Fsyei5aCBW0HXQBSHlcgzIxmrDLiRrZqE3QKBgBPu + KUUkY0EkTfIYa2LtqbUeKH5ZqQkFoCYpWcC3IQBVZqBCz0xeTumOxc5/9F7Ea9n+ + x8IJB11cmmJq+mqJNbpvegH3qKMnkP0kYcMdznm5EPybtMh9lxCKcWNnmvH7a+MC + sMHqCnvTsa8wOJUSWj+8yp5Xl2L3+wQ20VGYgR2NAoGAE8/BCLHcescaA9PGr4Kx + EfVfoULTXOJp8a8j+N3C8cXhnroXGT6IYnETb34Z2J713m/5Ko/74PBWXnRvvnu4 + ATPZbyXlmtsorV5E5lRs9x6U9bRUzDEWLp1yITfaDHflYUWkrtAKDZ6GslCMbvQz + 9/CbsOaHzDf73JYos55E78E= + -----END PRIVATE KEY----- + JWT_PUBLIC_KEY: |- + -----BEGIN PUBLIC KEY----- + MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAmugBJdEVFuN2EunH22JB + A6EsUFfFqBED7sDPTj3ayY0x2MF/fifXT4b3Z7kiBnGvOo4GMIOOcRuTAu2qziCB + jiPKwbl1sjxf4mZYlsVJsxDCLJmWBTU43s0ZfecpsuN2R/SQktqcNoE06meJZ/4T + 5JdijLPk8KO4rsedrIBUQMVmDzYKgqZG8xAC/swhyD2marGY0V/5xEu/8iWVsr0k + g0PwrHXkfsbLMdhdSV40B5evHQRCSXO4QFOMTarPWj+b2mi+j28Fjoeku284ng72 + p5MmUqwsVQWzMvPiR1wyy0E6fC798t/By/xM9IHn5tpEZxjU1NfZMKBU/nHnrmBO + kQIDAQAB + -----END PUBLIC KEY----- volumes: kmsdeps: diff --git a/shared/http/jwt.go b/shared/http/jwt.go new file mode 100644 index 0000000..45efbf8 --- /dev/null +++ b/shared/http/jwt.go @@ -0,0 +1,81 @@ +package http + +import ( + "context" + "crypto/rsa" + "crypto/x509" + "encoding/json" + "encoding/pem" + "errors" + "net/http" + "strings" + + "github.com/lestrrat-go/jwx/jwa" + "github.com/lestrrat-go/jwx/jwt" +) + +type keyResponse struct { + Key string `json:"key"` +} + +type contextKey string + +// ClaimsContextKey will be used to attach a JWT claim to a request context +const ClaimsContextKey contextKey = "claims" + +// JWTProtect uses the public key located at the given URL to check if the +// cookie value is signed properly. In case yes, the JWT claims will be added +// to the request context +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, err, http.StatusForbidden) + return + } + keyRes, keyErr := http.Get(keyURL) + if keyErr != nil { + RespondWithJSONError(w, keyErr, http.StatusInternalServerError) + return + } + defer keyRes.Body.Close() + payload := keyResponse{} + if err := json.NewDecoder(keyRes.Body).Decode(&payload); err != nil { + RespondWithJSONError(w, keyErr, http.StatusBadGateway) + return + } + keyBytes, _ := pem.Decode([]byte(payload.Key)) + if keyBytes == nil { + RespondWithJSONError(w, errors.New("no pem block found"), http.StatusInternalServerError) + return + } + parseResult, parseErr := x509.ParsePKIXPublicKey(keyBytes.Bytes) + if parseErr != nil { + RespondWithJSONError(w, parseErr, http.StatusBadGateway) + return + } + pubKey, pubKeyOk := parseResult.(*rsa.PublicKey) + if !pubKeyOk { + RespondWithJSONError(w, errors.New("unable to use given key"), http.StatusInternalServerError) + return + } + + token, jwtErr := jwt.ParseVerify(strings.NewReader(authCookie.Value), jwa.RS256, pubKey) + if jwtErr != nil { + RespondWithJSONError(w, jwtErr, http.StatusForbidden) + return + } + + if err := token.Verify(jwt.WithAcceptableSkew(0)); err != nil { + RespondWithJSONError(w, err, http.StatusForbidden) + return + } + + privateClaims, _ := token.Get("priv") + r = r.WithContext(context.WithValue(r.Context(), ClaimsContextKey, privateClaims)) + + next.ServeHTTP(w, r) + }) + } +}