From cce9d5c5135a602e4ac7c38105e2fb7474fb322b Mon Sep 17 00:00:00 2001 From: Jeremy Stott Date: Thu, 8 Aug 2019 01:20:32 +1200 Subject: [PATCH 1/2] Support authentication using a JSON web token (JWT) #93 * Created new configuration section for JWT Auth - Configure a JWK to verify a JWT signature - Configure requried signature algorithms - Configure required audience and issuer claims - Configure name of username claim * Added code block in lambda_handler_user to validate JWT if configured - Require remote_usernames == bastion_user - Require valid JWT signature, expiry, and signature algorithm - Require username_claim in JWT - Require username_claim == bastion_user * Added unit tests for config and JWT validation --- bless/aws_lambda/bless_lambda_user.py | 36 +++ bless/config/bless_config.py | 28 +++ bless/config/bless_deploy_example.cfg | 21 ++ bless/request/bless_request_user.py | 5 +- requirements.txt | 1 + tests/aws_lambda/bless-test-jwtauth.cfg | 12 + tests/aws_lambda/test_bless_lambda_user.py | 269 ++++++++++++++++++++- tests/config/full-with-jwtauth.cfg | 22 ++ tests/config/test_bless_config.py | 33 +++ 9 files changed, 424 insertions(+), 3 deletions(-) create mode 100644 tests/aws_lambda/bless-test-jwtauth.cfg create mode 100644 tests/config/full-with-jwtauth.cfg diff --git a/bless/aws_lambda/bless_lambda_user.py b/bless/aws_lambda/bless_lambda_user.py index 166bec9..3e68719 100644 --- a/bless/aws_lambda/bless_lambda_user.py +++ b/bless/aws_lambda/bless_lambda_user.py @@ -17,6 +17,13 @@ KMSAUTH_REMOTE_USERNAMES_ALLOWED_OPTION, \ VALIDATE_REMOTE_USERNAMES_AGAINST_IAM_GROUPS_OPTION, \ KMSAUTH_SERVICE_ID_OPTION, \ + JWTAUTH_SECTION, \ + JWTAUTH_USEJWTAUTH_OPTION, \ + JWTAUTH_SIGNATURE_JWK_OPTION, \ + JWTAUTH_AUDIENCE_OPTION, \ + JWTAUTH_ISSUER_OPTION, \ + JWTAUTH_SIGNATURE_ALGORITHM_OPTION, \ + JWTAUTH_USERNAME_CLAIM_OPTION, \ TEST_USER_OPTION, \ CERTIFICATE_EXTENSIONS_OPTION, \ REMOTE_USERNAMES_VALIDATION_OPTION, \ @@ -29,6 +36,8 @@ from bless.ssh.certificates.ssh_certificate_builder_factory import get_ssh_certificate_builder from kmsauth import KMSTokenValidator, TokenValidationError from marshmallow.exceptions import ValidationError +from jose import jwt +from jose.exceptions import JWTError def lambda_handler_user( @@ -159,6 +168,33 @@ def lambda_handler_user( else: return error_response('InputValidationError', 'Invalid request, missing kmsauth token') + # Authenticate the user with JWT, if key is configured + if config.getboolean(JWTAUTH_SECTION, JWTAUTH_USEJWTAUTH_OPTION): + if request.jwtauth_token: + + if request.remote_usernames != request.bastion_user: + return error_response('JWTAuthValidationError', + 'remote_usernames must be the same as bastion_user') + try: + claims = jwt.decode( + request.jwtauth_token, + config.get(JWTAUTH_SECTION, JWTAUTH_SIGNATURE_JWK_OPTION), + audience=config.get(JWTAUTH_SECTION, JWTAUTH_AUDIENCE_OPTION), + issuer=config.get(JWTAUTH_SECTION, JWTAUTH_ISSUER_OPTION), + algorithms=config.get(JWTAUTH_SECTION, JWTAUTH_SIGNATURE_ALGORITHM_OPTION) + ) + username_claim = config.get(JWTAUTH_SECTION, JWTAUTH_USERNAME_CLAIM_OPTION) + if username_claim not in claims.keys(): + return error_response('JWTAuthValidationError', + 'missing {} claim in jwt'.format(username_claim)) + if request.bastion_user != claims[username_claim]: + return error_response('JWTAuthValidationError', + 'bastion_user must equal {} claim in jwt'.format(username_claim)) + except JWTError as e: + return error_response('JWTAuthValidationError', str(e)) + else: + return error_response('InputValidationError', 'Invalid request, missing jwtauth_token') + # Build the cert ca = get_ssh_certificate_authority(ca_private_key, ca_private_key_password) cert_builder = get_ssh_certificate_builder(ca, SSHCertificateType.USER, diff --git a/bless/config/bless_config.py b/bless/config/bless_config.py index e5cf4a9..64e58f3 100644 --- a/bless/config/bless_config.py +++ b/bless/config/bless_config.py @@ -63,6 +63,25 @@ KMSAUTH_SERVICE_ID_OPTION = 'kmsauth_serviceid' KMSAUTH_SERVICE_ID_DEFAULT = None +JWTAUTH_SECTION = 'JWT Auth' +JWTAUTH_USEJWTAUTH_OPTION = 'use_jwtauth' +JWTAUTH_USEJWTAUTH_DEFAULT = 'False' + +JWTAUTH_SIGNATURE_JWK_OPTION = 'jwtauth_signature_jwk' +JWTAUTH_SIGNATURE_JWK_DEFAULT = '' + +JWTAUTH_SIGNATURE_ALGORITHM_OPTION = 'jwtauth_signature_algorithm' +JWTAUTH_SIGNATURE_ALGORITHM_DEFAULT = 'RS256' + +JWTAUTH_ISSUER_OPTION = 'jwtauth_issuer' +JWTAUTH_ISSUER_DEFAULT = '' + +JWTAUTH_AUDIENCE_OPTION = 'jwtauth_audience' +JWTAUTH_AUDIENCE_DEFAULT = '' + +JWTAUTH_USERNAME_CLAIM_OPTION = 'jwtauth_username_claim' +JWTAUTH_USERNAME_CLAIM_DEFAULT = 'email' + USERNAME_VALIDATION_OPTION = 'username_validation' USERNAME_VALIDATION_DEFAULT = 'useradd' @@ -102,6 +121,12 @@ def __init__(self, aws_region, config_file): KMSAUTH_KEY_ID_OPTION: KMSAUTH_KEY_ID_DEFAULT, KMSAUTH_REMOTE_USERNAMES_ALLOWED_OPTION: KMSAUTH_REMOTE_USERNAMES_ALLOWED_OPTION_DEFAULT, KMSAUTH_USEKMSAUTH_OPTION: KMSAUTH_USEKMSAUTH_DEFAULT, + JWTAUTH_USEJWTAUTH_OPTION: JWTAUTH_USEJWTAUTH_DEFAULT, + JWTAUTH_SIGNATURE_JWK_OPTION: JWTAUTH_SIGNATURE_JWK_DEFAULT, + JWTAUTH_SIGNATURE_ALGORITHM_OPTION: JWTAUTH_SIGNATURE_ALGORITHM_DEFAULT, + JWTAUTH_ISSUER_OPTION: JWTAUTH_ISSUER_DEFAULT, + JWTAUTH_AUDIENCE_OPTION: JWTAUTH_AUDIENCE_DEFAULT, + JWTAUTH_USERNAME_CLAIM_OPTION: JWTAUTH_USERNAME_CLAIM_DEFAULT, CERTIFICATE_EXTENSIONS_OPTION: CERTIFICATE_EXTENSIONS_DEFAULT, USERNAME_VALIDATION_OPTION: USERNAME_VALIDATION_DEFAULT, REMOTE_USERNAMES_VALIDATION_OPTION: REMOTE_USERNAMES_VALIDATION_DEFAULT, @@ -125,6 +150,9 @@ def __init__(self, aws_region, config_file): if not self.has_section(KMSAUTH_SECTION): self.add_section(KMSAUTH_SECTION) + if not self.has_section(JWTAUTH_SECTION): + self.add_section(JWTAUTH_SECTION) + if not self.has_option(BLESS_CA_SECTION, self.aws_region + REGION_PASSWORD_OPTION_SUFFIX): if not self.has_option(BLESS_CA_SECTION, 'default' + REGION_PASSWORD_OPTION_SUFFIX): raise ValueError("No Region Specific And No Default Password Provided.") diff --git a/bless/config/bless_deploy_example.cfg b/bless/config/bless_deploy_example.cfg index 0bc94ef..d765a4d 100644 --- a/bless/config/bless_deploy_example.cfg +++ b/bless/config/bless_deploy_example.cfg @@ -63,3 +63,24 @@ ca_private_key_file = # the group name is "ssh-{}".format(remote_username), but that can be changed here. The groups must have a # consistent naming scheme and must all contain the remote_username once. For example, ssh-ubuntu. # kmsauth_iam_group_name_format = ssh-{} + +# This section is optional +[JWT Auth] +# Enable authentication via a JWT, to ensure a username matches a claim in a valid JWT +# use_jwtauth = True + +# The JWK containing the public key used to verify the JWT signature, in JSON Web Key format https://tools.ietf.org/html/rfc7517 +# jwtauth_signature_jwk = {"kty": "RSA","e": "...","use": "sig","kid": "...","alg": "RS256","n": "..."} + +# The expected signature algorithm. JWTs signed with a different algorithm will be rejected. +# jwtauth_signature_algorithm = RS256 + +# The issuer present as the iss claim in the JWT. This must match the issuer from your identity provider. +# jwtauth_issuer = https://accounts.google.com + +# The audience present as the aud claim in the JWT. This must match the audience from your identity provider. +# jwtauth_audience = 1234567890-1234567890abcd.apps.googleusercontent.com + +# The claim in the JWT that contains the username. This is compared to the requested username, and will be set in the +# principals list of the SSH certificate. +# jwtauth_username_claim = email \ No newline at end of file diff --git a/bless/request/bless_request_user.py b/bless/request/bless_request_user.py index 1e31d80..81a70f7 100644 --- a/bless/request/bless_request_user.py +++ b/bless/request/bless_request_user.py @@ -90,6 +90,7 @@ class BlessUserSchema(Schema): public_key_to_sign = fields.Str(validate=validate_ssh_public_key, required=True) remote_usernames = fields.Str(required=True) kmsauth_token = fields.Str(required=False) + jwtauth_token = fields.Str(required=False) @validates_schema(pass_original=True) def check_unknown_fields(self, data, original_data): @@ -125,7 +126,7 @@ def validate_remote_usernames(self, remote_usernames): class BlessUserRequest: def __init__(self, bastion_ips, bastion_user, bastion_user_ip, command, public_key_to_sign, - remote_usernames, kmsauth_token=None): + remote_usernames, kmsauth_token=None, jwtauth_token=None): """ A BlessRequest must have the following key value pairs to be valid. :param bastion_ips: The source IPs where the SSH connection will be initiated from. This is @@ -138,6 +139,7 @@ def __init__(self, bastion_ips, bastion_user, bastion_user_ip, command, public_k :param remote_usernames: Comma-separated list of username(s) or authorized principals on the remote server that will be used in the SSH request. This is enforced in the issued certificate. :param kmsauth_token: An optional kms auth token to authenticate the user. + :param jwtauth_token: An optional jwt token to authenticate the user. """ self.bastion_ips = bastion_ips self.bastion_user = bastion_user @@ -146,6 +148,7 @@ def __init__(self, bastion_ips, bastion_user, bastion_user_ip, command, public_k self.public_key_to_sign = public_key_to_sign self.remote_usernames = remote_usernames self.kmsauth_token = kmsauth_token + self.jwtauth_token = jwtauth_token def __eq__(self, other): return self.__dict__ == other.__dict__ diff --git a/requirements.txt b/requirements.txt index 6ff53c8..c7f596c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,3 +14,4 @@ python-dateutil==2.8.0 s3transfer==0.2.0 six==1.12.0 urllib3==1.24.3 +python-jose[cryptography]==3.0.1 \ No newline at end of file diff --git a/tests/aws_lambda/bless-test-jwtauth.cfg b/tests/aws_lambda/bless-test-jwtauth.cfg new file mode 100644 index 0000000..ecc9ae4 --- /dev/null +++ b/tests/aws_lambda/bless-test-jwtauth.cfg @@ -0,0 +1,12 @@ +[Bless CA] +ca_private_key_file = tests/aws_lambda/only-use-for-unit-tests.pem +us-east-1_password = bogus-password-for-unit-test +us-west-2_password = bogus-password-for-unit-test + +[JWT Auth] +use_jwtauth = True +jwtauth_signature_jwk = {"kty": "RSA","e": "AQAB","use": "sig","kid": "key1","alg": "RS256","n": "7V4O45XkdzKedgfbg3U1X_UeGF00wQH6APcuRX_702h-3QZI4VmAbBFgDDAJgHa1wunKPUKmwfmzFodLX6Bd2UvgHtzhHDAnrHYSOpV0jci7zxUhPN84PBbNRKNG-yAGPvNk4YbCWHywz7BKmTVnG9q4KSdWaHpyhljxedMdkt2JqdTJcwaAEfqT_0A-gcBWxyCPwRJJRLColM9g6lZU7-17Y3UNHwBFC4lahfd009CXY7WMbKIJMG0LuBjsmCE4L__IlrFlevVFyA0ShDjDh07gKD-f5WJ6WdgcZOL7X3rf-DK6MRBUW4ItIpG7DVVWN0Vj6SNQT3x1kwq55mIZTw"} +jwtauth_signature_algorithm = RS256 +jwtauth_issuer = https://issuer.example.com +jwtauth_audience = 6c1d8893-9240-4f87-be95-1f21ef664ce0 +jwtauth_username_claim = username \ No newline at end of file diff --git a/tests/aws_lambda/test_bless_lambda_user.py b/tests/aws_lambda/test_bless_lambda_user.py index 40dce14..9974be1 100644 --- a/tests/aws_lambda/test_bless_lambda_user.py +++ b/tests/aws_lambda/test_bless_lambda_user.py @@ -1,18 +1,37 @@ import os import zlib - +import datetime import pytest from bless.aws_lambda.bless_lambda_user import lambda_handler_user from bless.aws_lambda.bless_lambda import lambda_handler from tests.ssh.vectors import EXAMPLE_RSA_PUBLIC_KEY, RSA_CA_PRIVATE_KEY_PASSWORD, \ EXAMPLE_ED25519_PUBLIC_KEY, EXAMPLE_ECDSA_PUBLIC_KEY - +from jose import jwt class Context(object): aws_request_id = 'bogus aws_request_id' invoked_function_arn = 'bogus invoked_function_arn' +JWTAUTH_JWK_SIGN = { + "kty": "RSA", + "d": "rCI3nedHZQF6VJHiKHTJHis9heGhrg3m5Ohbz96-GlN_HH3AQFuNe9El2_DCEz0DFrRACyjYkXao3r-Cc3hyVnBluTvoq25odvKwyXc0rNVTDRt_nQsrVrgaZ5oYkWhp3yDWmY4GRfE2r4ZisrQ9b7-vKXjzepTBlJfPlc75dVR5RoS5WISqt5jPPl2jGlbCmWw1Qb4N1TwCWXHtK5ns6IfeewlMyn7rpm3CfYblQlMGOorB6QzID4cEd2ogagJQIICPXlmbZ6N8qXEPWpVBQ0Krum91RmFButf0rUt-ODPe-BTmYLsa4txk5IFaHOLjzVjmq6AgRVxWmsA_rbOhMQ", + "e": "AQAB", + "use": "sig", + "kid": "key1", + "alg": "RS256", + "n": "7V4O45XkdzKedgfbg3U1X_UeGF00wQH6APcuRX_702h-3QZI4VmAbBFgDDAJgHa1wunKPUKmwfmzFodLX6Bd2UvgHtzhHDAnrHYSOpV0jci7zxUhPN84PBbNRKNG-yAGPvNk4YbCWHywz7BKmTVnG9q4KSdWaHpyhljxedMdkt2JqdTJcwaAEfqT_0A-gcBWxyCPwRJJRLColM9g6lZU7-17Y3UNHwBFC4lahfd009CXY7WMbKIJMG0LuBjsmCE4L__IlrFlevVFyA0ShDjDh07gKD-f5WJ6WdgcZOL7X3rf-DK6MRBUW4ItIpG7DVVWN0Vj6SNQT3x1kwq55mIZTw" +} + +JWTAUTH_JWK_BAD_SIGN = { + "kty": "RSA", + "d": "bGRl4H_ZRz4UaXXHpBjsGvSmEazJ0YJjWt_DNG3SjsHrFZwLXU2CWLP1-JAVe9Y2VRIuTKSdOBDbEWiSpAsH7iAirxKGqmIBLaYOrK170zrzWcToSGcIZziBbwTZpIy9Z55loXrtFjkObUfoEw7erHNNJfM0-jg79W_Phe89mtqbAf6twzB76yS4hcIzQdkTT_0q0PNj0n4DC8uDZC70gzHDGjtGUQmjw1ZXHCGFZaFESQjbv9-2SlhS5foLeNtuKCkQMRAeRaJ5_fLnJs61yVKwRzOz4r73yfTPlsYfhFXN5M4P6C1GOaDZANzl3uFsU88aCydEuFXEdWcRHeSw2Q", + "e": "AQAB", + "use": "sig", + "kid": "key1", + "alg": "RS256", + "n": "xPrwx5lWUhlPvH4qa791zUczNIPclGR3fnw6RHPtt8gExFfyChJ34lgHTRloEsRLTDyIfDgmTzGJHPBVYyxm8G7b3oC2KKbfczagb4Hfw0iIC1wXdp8PFiWy3L4qE6bh-3D0wwwqAQXyOx7ITa44oOYQzevYp637pzyCSZrInBDf9-TvyVjoO9erpbyHr7SnvIN8cccyqoQdpobG5N7vcSGWDXJrD1ZNKU624wAbe6ARUlOj7JdNxsFRO92IQSEZycTPo3aKhcQFqasQeRTNS_GChrcVvKfrBRt3KTWai9-hbtjlOetTfhtaGnbO2AxbMYgmis-_MSXTXs9VssqbVw" +} VALID_TEST_REQUEST = { "remote_usernames": "user", @@ -69,6 +88,31 @@ class Context(object): "kmsauth_token": "validkmsauthtoken", } +VALID_TEST_REQUEST_JWTAUTH = { + "remote_usernames": "user", + "public_key_to_sign": EXAMPLE_RSA_PUBLIC_KEY, + "command": "ssh user@server", + "bastion_ips": "127.0.0.1", + "bastion_user": "user", + "bastion_user_ip": "127.0.0.1", + "jwtauth_token": jwt.encode({ + "exp":datetime.datetime.utcnow() + datetime.timedelta(hours=1), + "iss":"https://issuer.example.com", + "aud":"6c1d8893-9240-4f87-be95-1f21ef664ce0", + "username": "user" + }, JWTAUTH_JWK_SIGN, algorithm="RS256"), +} + +INVALID_TEST_REQUEST_JWTAUTH = { + "remote_usernames": "user", + "public_key_to_sign": EXAMPLE_RSA_PUBLIC_KEY, + "command": "ssh user@server", + "bastion_ips": "127.0.0.1", + "bastion_user": "user", + "bastion_user_ip": "127.0.0.1", + "jwtauth_token": "", +} + INVALID_TEST_REQUEST_KEY_TYPE = { "remote_usernames": "user", "public_key_to_sign": EXAMPLE_ECDSA_PUBLIC_KEY, @@ -162,6 +206,123 @@ class Context(object): "bastion_user_ip": "127.0.0.1" } +INVALID_TEST_JWTAUTH_REQUEST_USERNAME_DOESNT_MATCH_REMOTE = { + "remote_usernames": "usera", + "public_key_to_sign": EXAMPLE_RSA_PUBLIC_KEY, + "command": "ssh user@server", + "bastion_ips": "127.0.0.1", + "bastion_user": "userb", + "bastion_user_ip": "127.0.0.1", + "jwtauth_token": jwt.encode({ + "iss":"https://issuer.example.com", + "aud":"6c1d8893-9240-4f87-be95-1f21ef664ce0", + "username": "user" + }, JWTAUTH_JWK_SIGN, algorithm="RS256"), +} + +INVALID_TEST_JWTAUTH_REQUEST_EXPIRED_JWTAUTH_TOKEN = { + "remote_usernames": "user", + "public_key_to_sign": EXAMPLE_RSA_PUBLIC_KEY, + "command": "ssh user@server", + "bastion_ips": "127.0.0.1", + "bastion_user": "user", + "bastion_user_ip": "127.0.0.1", + "jwtauth_token": jwt.encode({ + "exp":datetime.datetime.utcnow() - datetime.timedelta(hours=1), + "iss":"https://issuer.example.com", + "aud":"6c1d8893-9240-4f87-be95-1f21ef664ce0", + "username": "user" + }, JWTAUTH_JWK_SIGN, algorithm="RS256"), +} + +INVALID_TEST_JWTAUTH_REQUEST_INCORRECT_ISSUER = { + "remote_usernames": "user", + "public_key_to_sign": EXAMPLE_RSA_PUBLIC_KEY, + "command": "ssh user@server", + "bastion_ips": "127.0.0.1", + "bastion_user": "user", + "bastion_user_ip": "127.0.0.1", + "jwtauth_token": jwt.encode({ + "exp":datetime.datetime.utcnow() + datetime.timedelta(hours=1), + "iss":"https://bad.issuer", + "aud":"6c1d8893-9240-4f87-be95-1f21ef664ce0", + "username": "user" + }, JWTAUTH_JWK_SIGN, algorithm="RS256"), +} + +INVALID_TEST_JWTAUTH_REQUEST_INCORRECT_AUDIENCE = { + "remote_usernames": "user", + "public_key_to_sign": EXAMPLE_RSA_PUBLIC_KEY, + "command": "ssh user@server", + "bastion_ips": "127.0.0.1", + "bastion_user": "user", + "bastion_user_ip": "127.0.0.1", + "jwtauth_token": jwt.encode({ + "exp":datetime.datetime.utcnow() + datetime.timedelta(hours=1), + "iss":"https://issuer.example.com", + "aud":"bad.audience", + "username": "user" + }, JWTAUTH_JWK_SIGN, algorithm="RS256"), +} + +INVALID_TEST_JWTAUTH_REQUEST_MISSING_USERNAME_CLAIM = { + "remote_usernames": "user", + "public_key_to_sign": EXAMPLE_RSA_PUBLIC_KEY, + "command": "ssh user@server", + "bastion_ips": "127.0.0.1", + "bastion_user": "user", + "bastion_user_ip": "127.0.0.1", + "jwtauth_token": jwt.encode({ + "exp":datetime.datetime.utcnow() + datetime.timedelta(hours=1), + "iss":"https://issuer.example.com", + "aud":"6c1d8893-9240-4f87-be95-1f21ef664ce0" + }, JWTAUTH_JWK_SIGN, algorithm="RS256"), +} + +INVALID_TEST_JWTAUTH_REQUEST_USERNAME_CLAIM_DOESNT_MATCH_REMOTE_USER = { + "remote_usernames": "user", + "public_key_to_sign": EXAMPLE_RSA_PUBLIC_KEY, + "command": "ssh user@server", + "bastion_ips": "127.0.0.1", + "bastion_user": "user", + "bastion_user_ip": "127.0.0.1", + "jwtauth_token": jwt.encode({ + "exp":datetime.datetime.utcnow() + datetime.timedelta(hours=1), + "iss":"https://issuer.example.com", + "aud":"6c1d8893-9240-4f87-be95-1f21ef664ce0", + "username": "bad.user" + }, JWTAUTH_JWK_SIGN, algorithm="RS256"), +} + +INVALID_TEST_JWTAUTH_REQUEST_INCORRECT_SIGNATURE_ALGORITHM = { + "remote_usernames": "user", + "public_key_to_sign": EXAMPLE_RSA_PUBLIC_KEY, + "command": "ssh user@server", + "bastion_ips": "127.0.0.1", + "bastion_user": "user", + "bastion_user_ip": "127.0.0.1", + "jwtauth_token": jwt.encode({ + "exp":datetime.datetime.utcnow() + datetime.timedelta(hours=1), + "iss":"https://issuer.example.com", + "aud":"6c1d8893-9240-4f87-be95-1f21ef664ce0", + "username": "user" + }, JWTAUTH_JWK_SIGN, algorithm="RS512"), +} + +INVALID_TEST_JWTAUTH_REQUEST_INCORRECT_SIGNATURE = { + "remote_usernames": "user", + "public_key_to_sign": EXAMPLE_RSA_PUBLIC_KEY, + "command": "ssh user@server", + "bastion_ips": "127.0.0.1", + "bastion_user": "user", + "bastion_user_ip": "127.0.0.1", + "jwtauth_token": jwt.encode({ + "exp":datetime.datetime.utcnow() + datetime.timedelta(hours=1), + "iss":"https://issuer.example.com", + "aud":"6c1d8893-9240-4f87-be95-1f21ef664ce0", + "username": "user" + }, JWTAUTH_JWK_BAD_SIGN, algorithm="RS256"), +} def test_basic_local_request_with_wrapper(): output = lambda_handler(VALID_TEST_REQUEST, context=Context, @@ -203,6 +364,22 @@ def test_basic_local_missing_kmsauth_request(): 'bless-test-kmsauth.cfg')) assert output['errorType'] == 'InputValidationError' +def test_basic_local_unused_jwtauth_request(): + output = lambda_handler_user(VALID_TEST_REQUEST_JWTAUTH, context=Context, + ca_private_key_password=RSA_CA_PRIVATE_KEY_PASSWORD, + entropy_check=False, + config_file=os.path.join(os.path.dirname(__file__), 'bless-test.cfg')) + assert output['certificate'].startswith('ssh-rsa-cert-v01@openssh.com ') + + +def test_basic_local_missing_jwtauth_request(): + output = lambda_handler_user(VALID_TEST_REQUEST, context=Context, + ca_private_key_password=RSA_CA_PRIVATE_KEY_PASSWORD, + entropy_check=False, + config_file=os.path.join(os.path.dirname(__file__), + 'bless-test-jwtauth.cfg')) + assert output['errorType'] == 'InputValidationError' + def test_basic_local_username_validation_disabled(monkeypatch): extra_environment_variables = { @@ -390,6 +567,14 @@ def test_invalid_kmsauth_request(): 'bless-test-kmsauth.cfg')) assert output['errorType'] == 'KMSAuthValidationError' +def test_invalid_jwtauth_request(): + output = lambda_handler_user(INVALID_TEST_REQUEST_JWTAUTH, context=Context, + ca_private_key_password=RSA_CA_PRIVATE_KEY_PASSWORD, + entropy_check=False, + config_file=os.path.join(os.path.dirname(__file__), + 'bless-test-jwtauth.cfg')) + assert output['errorType'] == 'InputValidationError' + def test_invalid_request(): output = lambda_handler_user(INVALID_TEST_REQUEST, context=Context, @@ -556,3 +741,83 @@ def test_basic_local_request_blacklisted(monkeypatch): entropy_check=False, config_file=os.path.join(os.path.dirname(__file__), 'bless-test.cfg')) assert output['errorType'] == 'InputValidationError' + + +def test_invalid_jwtauth_request_with_mismatched_bastion_and_remote(): + output = lambda_handler_user(INVALID_TEST_JWTAUTH_REQUEST_USERNAME_DOESNT_MATCH_REMOTE, context=Context, + ca_private_key_password=RSA_CA_PRIVATE_KEY_PASSWORD, + entropy_check=False, + config_file=os.path.join(os.path.dirname(__file__), + 'bless-test-jwtauth.cfg')) + assert output['errorType'] == 'JWTAuthValidationError' + +def test_valid_jwtauth_request(): + output = lambda_handler_user(VALID_TEST_REQUEST_JWTAUTH, context=Context, + ca_private_key_password=RSA_CA_PRIVATE_KEY_PASSWORD, + entropy_check=False, + config_file=os.path.join(os.path.dirname(__file__), + 'bless-test-jwtauth.cfg')) + assert output['certificate'].startswith('ssh-rsa-cert-v01@openssh.com ') + +def test_invalid_jwtauth_request_with_expired_jwtauth_token(): + output = lambda_handler_user(INVALID_TEST_JWTAUTH_REQUEST_EXPIRED_JWTAUTH_TOKEN, context=Context, + ca_private_key_password=RSA_CA_PRIVATE_KEY_PASSWORD, + entropy_check=False, + config_file=os.path.join(os.path.dirname(__file__), + 'bless-test-jwtauth.cfg')) + assert output['errorType'] == 'JWTAuthValidationError' + assert output['errorMessage'] == 'Signature has expired.' + +def test_invalid_jwtauth_request_with_incorrect_issuer(): + output = lambda_handler_user(INVALID_TEST_JWTAUTH_REQUEST_INCORRECT_ISSUER, context=Context, + ca_private_key_password=RSA_CA_PRIVATE_KEY_PASSWORD, + entropy_check=False, + config_file=os.path.join(os.path.dirname(__file__), + 'bless-test-jwtauth.cfg')) + assert output['errorType'] == 'JWTAuthValidationError' + assert output['errorMessage'] == 'Invalid issuer' + +def test_invalid_jwtauth_request_with_incorrect_audience(): + output = lambda_handler_user(INVALID_TEST_JWTAUTH_REQUEST_INCORRECT_AUDIENCE, context=Context, + ca_private_key_password=RSA_CA_PRIVATE_KEY_PASSWORD, + entropy_check=False, + config_file=os.path.join(os.path.dirname(__file__), + 'bless-test-jwtauth.cfg')) + assert output['errorType'] == 'JWTAuthValidationError' + assert output['errorMessage'] == 'Invalid audience' + +def test_invalid_jwtauth_request_with_missing_username_claim(): + output = lambda_handler_user(INVALID_TEST_JWTAUTH_REQUEST_MISSING_USERNAME_CLAIM, context=Context, + ca_private_key_password=RSA_CA_PRIVATE_KEY_PASSWORD, + entropy_check=False, + config_file=os.path.join(os.path.dirname(__file__), + 'bless-test-jwtauth.cfg')) + assert output['errorType'] == 'JWTAuthValidationError' + assert output['errorMessage'] == 'missing username claim in jwt' + +def test_invalid_jwtauth_request_with_username_claim_not_matching_remote_user(): + output = lambda_handler_user(INVALID_TEST_JWTAUTH_REQUEST_USERNAME_CLAIM_DOESNT_MATCH_REMOTE_USER, context=Context, + ca_private_key_password=RSA_CA_PRIVATE_KEY_PASSWORD, + entropy_check=False, + config_file=os.path.join(os.path.dirname(__file__), + 'bless-test-jwtauth.cfg')) + assert output['errorType'] == 'JWTAuthValidationError' + assert output['errorMessage'] == 'bastion_user must equal username claim in jwt' + +def test_invalid_jwtauth_request_with_incorrect_signature_algorithm(): + output = lambda_handler_user(INVALID_TEST_JWTAUTH_REQUEST_INCORRECT_SIGNATURE_ALGORITHM, context=Context, + ca_private_key_password=RSA_CA_PRIVATE_KEY_PASSWORD, + entropy_check=False, + config_file=os.path.join(os.path.dirname(__file__), + 'bless-test-jwtauth.cfg')) + assert output['errorType'] == 'JWTAuthValidationError' + assert output['errorMessage'] == 'The specified alg value is not allowed' + +def test_invalid_jwtauth_request_with_incorrect_signature(): + output = lambda_handler_user(INVALID_TEST_JWTAUTH_REQUEST_INCORRECT_SIGNATURE, context=Context, + ca_private_key_password=RSA_CA_PRIVATE_KEY_PASSWORD, + entropy_check=False, + config_file=os.path.join(os.path.dirname(__file__), + 'bless-test-jwtauth.cfg')) + assert output['errorType'] == 'JWTAuthValidationError' + assert output['errorMessage'] == 'Signature verification failed.' \ No newline at end of file diff --git a/tests/config/full-with-jwtauth.cfg b/tests/config/full-with-jwtauth.cfg new file mode 100644 index 0000000..42fe245 --- /dev/null +++ b/tests/config/full-with-jwtauth.cfg @@ -0,0 +1,22 @@ +[Bless Options] +# The default values are sane, these are not. +certificate_validity_after_seconds = 1 +certificate_validity_before_seconds = 1 +entropy_minimum_bits = 2 +random_seed_bytes = 3 +logging_level = DEBUG +username_validation = debian + +[Bless CA] +us-east-1_password = +us-west-2_password = +ca_private_key_file = +ca_private_key_compression = zlib + +[JWT Auth] +use_jwtauth = True +jwtauth_signature_jwk = {"kty": "RSA","e": "...","use": "sig","kid": "...","alg": "RS256","n": "..."} +jwtauth_signature_algorithm = ABCD +jwtauth_issuer = https://issuer.example.com +jwtauth_audience = 6c1d8893-9240-4f87-be95-1f21ef664ce0 +jwtauth_username_claim = username \ No newline at end of file diff --git a/tests/config/test_bless_config.py b/tests/config/test_bless_config.py index 70e907c..2ca17cc 100644 --- a/tests/config/test_bless_config.py +++ b/tests/config/test_bless_config.py @@ -20,6 +20,13 @@ KMSAUTH_USEKMSAUTH_OPTION, \ KMSAUTH_KEY_ID_OPTION, \ KMSAUTH_SERVICE_ID_OPTION, \ + JWTAUTH_SECTION, \ + JWTAUTH_USEJWTAUTH_OPTION, \ + JWTAUTH_SIGNATURE_JWK_OPTION, \ + JWTAUTH_SIGNATURE_ALGORITHM_OPTION, \ + JWTAUTH_USERNAME_CLAIM_OPTION, \ + JWTAUTH_ISSUER_OPTION, \ + JWTAUTH_AUDIENCE_OPTION, \ CERTIFICATE_EXTENSIONS_OPTION, \ USERNAME_VALIDATION_OPTION, \ USERNAME_VALIDATION_DEFAULT, \ @@ -142,6 +149,11 @@ def test_config_environment_override(monkeypatch): 'kms_auth_use_kmsauth': 'True', 'kms_auth_kmsauth_key_id': '', 'kms_auth_kmsauth_serviceid': 'bless-test', + + 'jwt_auth_use_jwtauth': 'True', + 'jwt_auth_jwtauth_signature_jwk': '{"kid":"key1"}', + 'jwt_auth_jwtauth_issuer': 'bless-issuer', + 'jwt_auth_jwtauth_audience': 'bless-audience', } for k, v in extra_environment_variables.items(): @@ -170,6 +182,11 @@ def test_config_environment_override(monkeypatch): assert '' == config.get(KMSAUTH_SECTION, KMSAUTH_KEY_ID_OPTION) assert 'bless-test' == config.get(KMSAUTH_SECTION, KMSAUTH_SERVICE_ID_OPTION) + assert config.getboolean(JWTAUTH_SECTION, JWTAUTH_USEJWTAUTH_OPTION) + assert '{"kid":"key1"}' == config.get(JWTAUTH_SECTION, JWTAUTH_SIGNATURE_JWK_OPTION) + assert 'bless-issuer' == config.get(JWTAUTH_SECTION, JWTAUTH_ISSUER_OPTION) + assert 'bless-audience' == config.get(JWTAUTH_SECTION, JWTAUTH_AUDIENCE_OPTION) + config.aws_region = 'invalid' assert '' == config.getpassword() @@ -242,3 +259,19 @@ def test_kms_config_opts(monkeypatch): config = BlessConfig("us-east-1", config_file=os.path.join(os.path.dirname(__file__), 'full-with-kmsauth.cfg')) assert config.getboolean(KMSAUTH_SECTION, KMSAUTH_USEKMSAUTH_OPTION) is True assert config.getboolean(KMSAUTH_SECTION, VALIDATE_REMOTE_USERNAMES_AGAINST_IAM_GROUPS_OPTION) is False + +def test_jwt_config_opts(monkeypatch): + # Default option + config = BlessConfig("us-east-1", config_file=os.path.join(os.path.dirname(__file__), 'full.cfg')) + assert config.getboolean(JWTAUTH_SECTION, JWTAUTH_USEJWTAUTH_OPTION) is False + assert config.get(JWTAUTH_SECTION, JWTAUTH_SIGNATURE_ALGORITHM_OPTION) == 'RS256' + assert config.get(JWTAUTH_SECTION, JWTAUTH_USERNAME_CLAIM_OPTION) == 'email' + + # Config file value + config = BlessConfig("us-east-1", config_file=os.path.join(os.path.dirname(__file__), 'full-with-jwtauth.cfg')) + assert config.getboolean(JWTAUTH_SECTION, JWTAUTH_USEJWTAUTH_OPTION) is True + assert config.get(JWTAUTH_SECTION, JWTAUTH_SIGNATURE_JWK_OPTION) == '{"kty": "RSA","e": "...","use": "sig","kid": "...","alg": "RS256","n": "..."}' + assert config.get(JWTAUTH_SECTION, JWTAUTH_SIGNATURE_ALGORITHM_OPTION) == 'ABCD' + assert config.get(JWTAUTH_SECTION, JWTAUTH_USERNAME_CLAIM_OPTION) == 'username' + assert config.get(JWTAUTH_SECTION, JWTAUTH_ISSUER_OPTION) == 'https://issuer.example.com' + assert config.get(JWTAUTH_SECTION, JWTAUTH_AUDIENCE_OPTION) == '6c1d8893-9240-4f87-be95-1f21ef664ce0' From 24af4b4ececa3472bc4cba7235e448f17f1c8807 Mon Sep 17 00:00:00 2001 From: Jeremy Stott Date: Mon, 4 Nov 2019 19:40:30 +1300 Subject: [PATCH 2/2] Fix marshmellow verison, and prevent jose from verifying access token hash * Pin marshmellow to a version less than 3. This might be fixed in #99 * Disable at_hash verification if present in the JWT, since the bless lambda doesn't have access to the a hash of the access token. --- bless/aws_lambda/bless_lambda_user.py | 3 ++- setup.py | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/bless/aws_lambda/bless_lambda_user.py b/bless/aws_lambda/bless_lambda_user.py index 3e68719..79767ec 100644 --- a/bless/aws_lambda/bless_lambda_user.py +++ b/bless/aws_lambda/bless_lambda_user.py @@ -181,7 +181,8 @@ def lambda_handler_user( config.get(JWTAUTH_SECTION, JWTAUTH_SIGNATURE_JWK_OPTION), audience=config.get(JWTAUTH_SECTION, JWTAUTH_AUDIENCE_OPTION), issuer=config.get(JWTAUTH_SECTION, JWTAUTH_ISSUER_OPTION), - algorithms=config.get(JWTAUTH_SECTION, JWTAUTH_SIGNATURE_ALGORITHM_OPTION) + algorithms=config.get(JWTAUTH_SECTION, JWTAUTH_SIGNATURE_ALGORITHM_OPTION), + options={'verify_at_hash': False} ) username_claim = config.get(JWTAUTH_SECTION, JWTAUTH_USERNAME_CLAIM_OPTION) if username_claim not in claims.keys(): diff --git a/setup.py b/setup.py index f148b9d..d1b4563 100644 --- a/setup.py +++ b/setup.py @@ -21,8 +21,9 @@ 'boto3', 'cryptography', 'ipaddress', - 'marshmallow', - 'kmsauth' + 'marshmallow<3', + 'kmsauth', + 'python-jose[cryptography]' ], extras_require={ 'tests': [