diff --git a/.circleci/config.yml b/.circleci/config.yml index 98b06c2..8888d2b 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: @@ -229,10 +277,10 @@ jobs: key: offen-packages-{{ checksum "package.json" }} - run: name: Deploy - working_directory: ~/offen + working_directory: ~/offen/accounts command: | echo "Deploying accounts ..." - $(npm bin)/sls deploy --config accounts/serverless.yml + $(npm bin)/sls deploy deploy_golang: docker: 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..cb325f6 100644 --- a/accounts/accounts/__init__.py +++ b/accounts/accounts/__init__.py @@ -1,13 +1,5 @@ -from flask import Flask, jsonify, render_template +from flask import Flask app = Flask(__name__) - -@app.route("/") -def home(): - return render_template("index.html") - - -@app.route("/status") -def status(): - return jsonify({"ok": True}) +import accounts.views 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/accounts/views.py b/accounts/accounts/views.py new file mode 100644 index 0000000..e1aad76 --- /dev/null +++ b/accounts/accounts/views.py @@ -0,0 +1,69 @@ +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 +from passlib.hash import bcrypt +import jwt + +from accounts import app + +@app.route("/") +def home(): + return render_template("index.html") + + +@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"), + samesite="strict" + ) + 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/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..e91e05d 100644 --- a/accounts/serverless.yml +++ b/accounts/serverless.yml @@ -14,9 +14,6 @@ provider: package: individually: true - excludeDevDependencies: false - exclude: - - '**/*' plugins: - serverless-domain-manager @@ -25,10 +22,18 @@ 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 alpha: accounts-alpha.offen.dev + cookieDomain: + production: .offen.dev + staging: .offen.dev + alpha: .offen.dev customDomain: basePath: '' certificateName: '*.offen.dev' @@ -37,18 +42,15 @@ custom: endpointType: regional createRoute53Record: false wsgi: - app: accounts.accounts.app + app: accounts.app packRequirements: false pythonRequirements: slim: true dockerizePip: non-linux - fileName: accounts/requirements.txt + fileName: requirements.txt functions: app: - package: - include: - - accounts/**/* handler: wsgi_handler.handler events: - http: @@ -57,3 +59,10 @@ functions: - http: path: '{proxy+}' method: any + environment: + USER: offen + CORS_ORIGIN: https://${self:custom.origin.${self:custom.stage}} + COOKIE_DOMAIN: ${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/accounts/accounts/tests/__init__.py b/accounts/tests/__init__.py similarity index 100% rename from accounts/accounts/tests/__init__.py rename to accounts/tests/__init__.py diff --git a/accounts/tests/test_jwt.py b/accounts/tests/test_jwt.py new file mode 100644 index 0000000..2b0d423 --- /dev/null +++ b/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/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..686d682 --- /dev/null +++ b/shared/http/jwt.go @@ -0,0 +1,93 @@ +package http + +import ( + "context" + "crypto/rsa" + "crypto/x509" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/lestrrat-go/jwx/jwa" + "github.com/lestrrat-go/jwx/jwt" +) + +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, fmt.Errorf("jwt: error reading cookie: %s", err), http.StatusForbidden) + return + } + + keyRes, keyErr := fetchKey(keyURL) + if keyErr != nil { + RespondWithJSONError(w, fmt.Errorf("jwt: error fetching key: %v", keyErr), http.StatusInternalServerError) + return + } + + keyBytes, _ := pem.Decode([]byte(keyRes)) + if keyBytes == nil { + RespondWithJSONError(w, errors.New("jwt: no PEM block found in given key"), http.StatusInternalServerError) + return + } + + parseResult, parseErr := x509.ParsePKIXPublicKey(keyBytes.Bytes) + if parseErr != nil { + RespondWithJSONError(w, fmt.Errorf("jwt: error parsing key: %v", parseErr), http.StatusBadGateway) + return + } + + pubKey, pubKeyOk := parseResult.(*rsa.PublicKey) + if !pubKeyOk { + RespondWithJSONError(w, errors.New("jwt: given key is not of type RSA public key"), http.StatusInternalServerError) + return + } + + token, jwtErr := jwt.ParseVerify(strings.NewReader(authCookie.Value), jwa.RS256, pubKey) + if jwtErr != nil { + RespondWithJSONError(w, fmt.Errorf("jwt: error parsing token: %v", jwtErr), http.StatusForbidden) + return + } + + if err := token.Verify(jwt.WithAcceptableSkew(0)); err != nil { + RespondWithJSONError(w, fmt.Errorf("jwt: error verifying token: %v", err), http.StatusForbidden) + return + } + + privateClaims, _ := token.Get("priv") + r = r.WithContext(context.WithValue(r.Context(), ClaimsContextKey, privateClaims)) + + next.ServeHTTP(w, r) + }) + } +} + +type keyResponse struct { + Key string `json:"key"` +} + +func fetchKey(keyURL string) ([]byte, error) { + fetchRes, fetchErr := http.Get(keyURL) + if fetchErr != nil { + return nil, fetchErr + } + defer fetchRes.Body.Close() + payload := keyResponse{} + if err := json.NewDecoder(fetchRes.Body).Decode(&payload); err != nil { + return nil, err + } + return []byte(payload.Key), nil +} diff --git a/shared/http/jwt_test.go b/shared/http/jwt_test.go new file mode 100644 index 0000000..4f3c2f7 --- /dev/null +++ b/shared/http/jwt_test.go @@ -0,0 +1,191 @@ +package http + +import ( + "crypto/x509" + "encoding/pem" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/lestrrat-go/jwx/jwa" + + "github.com/lestrrat-go/jwx/jwt" +) + +const publicKey = ` +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2yUfHH6SRYKvBTemrefi +Hk4L4qkcc4skl4QCaHOkfgA4VcGKG2nXysYuZK7AzNOcHQVi+e4BwN+BfIZtwEU5 +7Ogctb5eg8ksxxLjS7eSRfQIvPGfAbJ12R9OoOWcue/CdUy/YMec4R/o4+tZ45S6 +QQWIMhLqYljw+s1Runda3K8Q8lOdJ4yEZckXaZr1waNJikC7oGpT7ClAgdbvWIbo +N18G1OluRn+3WNdcN6V+vIj8c9dGs92bgTPX4cn3RmB/80BDfzeFiPMRw5xaq66F +42zXzllkTqukQPk2wmO5m9pFy0ciRve+awfgbTtZRZOEpTSWLbbpOfd4RQ5YqDWJ +mQIDAQAB +-----END PUBLIC KEY----- +` + +const privateKey = ` +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDbJR8cfpJFgq8F +N6at5+IeTgviqRxziySXhAJoc6R+ADhVwYobadfKxi5krsDM05wdBWL57gHA34F8 +hm3ARTns6By1vl6DySzHEuNLt5JF9Ai88Z8BsnXZH06g5Zy578J1TL9gx5zhH+jj +61njlLpBBYgyEupiWPD6zVG6d1rcrxDyU50njIRlyRdpmvXBo0mKQLugalPsKUCB +1u9Yhug3XwbU6W5Gf7dY11w3pX68iPxz10az3ZuBM9fhyfdGYH/zQEN/N4WI8xHD +nFqrroXjbNfOWWROq6RA+TbCY7mb2kXLRyJG975rB+BtO1lFk4SlNJYttuk593hF +DlioNYmZAgMBAAECggEADvr6pXgBh77nN/QV8M1pJ6kuJtBooX1hgvoDMCC3neVl +9HbGehlCJxplEXzgsR/GDDXSDkO22vhsYZbO6dXRn+A+Fi5tR5T4+qLP5t0loqKL +9l6OAA+y/qSlO1p23D8Hi/0zF+qNTtZflTUBcA06rjcymDmyzAZIctyWOajvDSbK +Df0ZvKYPnwG5gjF01hPS2VJicv/O0HXLN7elq/jio1dwvLa2JjPyXhWBkHqnJBcq +ncWP9IEJQmhQ8ijNEg78uLtiNZQ4+GcXNBlwhM7JER6X/AxSxEZ/7fjZog685yUH +3iF820SnStOJQQci/RMMPOsK6cM7BiJxGp2W12EOAQKBgQD85UdCDro6zpblpAw7 +Gw82SkWGksJXuGlTX+nj3/3iIiEb4ATCvZufYXALGNtiG0tPHDMBQCKLYrbLE1pt +9uIU/IbDFPeQk8rR/b7IHu0gv3463p6r7WVhzY2/JCororKYQk4zbuk3cNYtlV76 +ojnNY1EFDLK/1nGT6QDxDA7Z5wKBgQDd1chB2qlbljRzYFwKrWXZ0COtbnEGPnUz +rLvSlAvYlZSKuB/vXkHGepxdlAjDGgX6xkKSl1TKb8UWQ9JSv0MPGBcMPukuwCAL +BOobyvd1mln6f/C7FrATkRbrG+r8RAQTwR+eknwYYOPAS/PpXm8gZvVntiahihFd +NqQtud8QfwKBgQDGV+xzWqmkxbKDmQ4erTJZGhc9XI0fz3qL8YW3O04btTjSa/hP +4/XSItGFYpFteIqwGSXHrU1qlJlY3GzoIeFfJE9tYVxpAADqgWDIA7lnHcka0s8P +eLky48xwRSTt5ES+NgKvRCWVXeIdDjHX0LQU6ff5ReRLoRyjLPOYGiTrsQKBgAmq +z1dPWCINoauFf31XoSCk2Wktbu9+uUzPMkAzA3Ek05xX+cxMp0EnBrltQhR+hdQv +36bTwXYw+L3HptrESv/VZOu7sh2/caYJSMp9RdtyJomsGamNi47Ou9jzFoJ31FWo +DOC0MYQ+dK5koPSCkQUwd3FVlsljYu5U+0Ki3v2xAoGASIMhNHOvz+Ay2otovVFN +gfRGTnepw8znHbkr10IG97BWd4VbFnHRdpYbtk8fH0UOyUVMrcY0B2/d73Rzqze3 +iZ//FXIDTtmKnVS/ZhC2w0AH8Piziy3NW3G6jRZN6+9NpOf/BIc4pfzgUJ3RqHz/ +IeONX+52k6gz1SCjPgSUlTs= +-----END PRIVATE KEY----- +` + +func TestJWTProtect(t *testing.T) { + tests := []struct { + name string + cookie *http.Cookie + server *httptest.Server + expectedStatusCode int + }{ + { + "no cookie", + nil, + nil, + http.StatusForbidden, + }, + { + "bad server", + &http.Cookie{ + Name: "auth", + Value: "irrelevantgibberish", + }, + nil, + http.StatusInternalServerError, + }, + { + "non-json response", + &http.Cookie{ + Name: "auth", + Value: "irrelevantgibberish", + }, + httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("here's some bytes 4 y'all")) + })), + http.StatusInternalServerError, + }, + { + "bad key value", + &http.Cookie{ + Name: "auth", + Value: "irrelevantgibberish", + }, + httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"key":"not really a key"}`)) + })), + http.StatusInternalServerError, + }, + { + "invalid key", + &http.Cookie{ + Name: "auth", + Value: "irrelevantgibberish", + }, + httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"key":"-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCATZAMIIBCgKCAQEA2yUfHH6SRYKvBTemrefi\nHk4L4qkcc4skl4QCaHOkfgA4VcGKG2nXysYuZK7AzNOcHQVi+e4BwN+BfIZtwEU5\n7Ogctb5eg8ksxxLjS7eSRfQIvPGfAbJ12R9OoOWcue/CdUy/YMec4R/o4+tZ45S6\nQQWIMhLqYljw+s1Runda3K8Q8lOdJ4yEZckXaZr1waNJikC7oGpT7ClAgdbvWIbo\nN18G1OluRn+3WNdcN6V+vIj8c9dGs92bgTPX4cn3RmB/80BDfzeFiPMRw5xaq66F\n42zXzllkTqukQPk2wmO5m9pFy0ciRve+awfgbTtZRZOEpTSWLbbpOfd4RQ5YqDWJ\nmQIDAQAB\n-----END PUBLIC KEY-----"}`)) + })), + http.StatusBadGateway, + }, + { + "valid key, bad auth token", + &http.Cookie{ + Name: "auth", + Value: "irrelevantgibberish", + }, + httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte( + fmt.Sprintf(`{"key":"%s"}`, strings.ReplaceAll(publicKey, "\n", `\n`)), + )) + })), + http.StatusForbidden, + }, + { + "valid key, valid token", + &http.Cookie{ + Name: "auth", + Value: (func() string { + token := jwt.New() + token.Set("exp", time.Now().Add(time.Hour)) + keyBytes, _ := pem.Decode([]byte(privateKey)) + privKey, _ := x509.ParsePKCS8PrivateKey(keyBytes.Bytes) + b, _ := token.Sign(jwa.RS256, privKey) + return string(b) + })(), + }, + httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte( + fmt.Sprintf(`{"key":"%s"}`, strings.ReplaceAll(publicKey, "\n", `\n`)), + )) + })), + http.StatusOK, + }, + { + "valid key, expired token", + &http.Cookie{ + Name: "auth", + Value: (func() string { + token := jwt.New() + token.Set("exp", time.Now().Add(-time.Hour)) + keyBytes, _ := pem.Decode([]byte(privateKey)) + privKey, _ := x509.ParsePKCS8PrivateKey(keyBytes.Bytes) + b, _ := token.Sign(jwa.RS256, privKey) + return string(b) + })(), + }, + httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte( + fmt.Sprintf(`{"key":"%s"}`, strings.ReplaceAll(publicKey, "\n", `\n`)), + )) + })), + http.StatusForbidden, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var url string + if test.server != nil { + url = test.server.URL + } + wrappedHandler := JWTProtect(url, "auth")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("OK")) + })) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/", nil) + if test.cookie != nil { + r.AddCookie(test.cookie) + } + wrappedHandler.ServeHTTP(w, r) + if w.Code != test.expectedStatusCode { + t.Errorf("Unexpected status code %v", w.Code) + } + }) + } +} diff --git a/shared/http/middleware.go b/shared/http/middleware.go index 30c9e79..d1dc3ee 100644 --- a/shared/http/middleware.go +++ b/shared/http/middleware.go @@ -52,7 +52,7 @@ func UserCookieMiddleware(cookieKey string, contextKey interface{}) func(http.Ha return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { c, err := r.Cookie(cookieKey) if err != nil { - RespondWithJSONError(w, errors.New("received no or blank user identifier"), http.StatusBadRequest) + RespondWithJSONError(w, errors.New("user cookie: received no or blank identifier"), http.StatusBadRequest) return } r = r.WithContext( diff --git a/shared/http/middleware_test.go b/shared/http/middleware_test.go index 13654b1..633d44b 100644 --- a/shared/http/middleware_test.go +++ b/shared/http/middleware_test.go @@ -83,7 +83,7 @@ func TestUserCookieMiddleware(t *testing.T) { if w.Code != http.StatusBadRequest { t.Errorf("Unexpected status code %v", w.Code) } - if !strings.Contains(w.Body.String(), "received no or blank user identifier") { + if !strings.Contains(w.Body.String(), "received no or blank identifier") { t.Errorf("Unexpected body %s", w.Body.String()) } }) @@ -99,7 +99,7 @@ func TestUserCookieMiddleware(t *testing.T) { if w.Code != http.StatusBadRequest { t.Errorf("Unexpected status code %v", w.Code) } - if !strings.Contains(w.Body.String(), "received no or blank user identifier") { + if !strings.Contains(w.Body.String(), "received no or blank identifier") { t.Errorf("Unexpected body %s", w.Body.String()) } })