Authenticating the requests
The logic for authentication is encapsulated in the thoughts_backend/token_validation.py file. This contains both the generation and the validation of the header.
The following functions generate the Bearer token:
def encode_token(payload, private_key):
return jwt.encode(payload, private_key, algorithm='RS256')
def generate_token_header(username, private_key):
'''
Generate a token header base on the username.
Sign using the private key.
'''
payload = {
'username': username,
'iat': datetime.utcnow(),
'exp': datetime.utcnow() + timedelta(days=2),
}
token = encode_token(payload, private_key)
token = token.decode('utf8')
return f'Bearer {token}'
This generates a JWT payload. It includes username to be used as a custom value, but it also adds two standard fields, an exp expiration date and the iat generation time of the token.
The token is then encoded using the RS256 algorithm, with a private key, and returned in the proper format: Bearer <token>.
The reverse action is to obtain the username from an encoded header. The code here is longer, as we should account for the different options in which we may receive the Authentication header. This header comes directly from our public API, so we should expect any value and program to be defensively ready for it.
The decoding of the token itself is straightforward, as the jwt.decode action will do this:
def decode_token(token, public_key):
return jwt.decode(token, public_key, algoritms='RS256')
But before arriving at that step, we need to obtain the token and verify that the header is valid in multiple cases, so we check first whether the header is empty, and whether it has the proper format, extracting the token:
def validate_token_header(header, public_key):
if not header:
logger.info('No header')
return None
# Retrieve the Bearer token
parse_result = parse('Bearer {}', header)
if not parse_result:
logger.info(f'Wrong format for header "{header}"')
return None
token = parse_result[0]
Then, we decode the token. If the token cannot be decoded with the public key, it raises DecodeError. The token can also be expired:
try:
decoded_token = decode_token(token.encode('utf8'), public_key)
except jwt.exceptions.DecodeError:
logger.warning(f'Error decoding header "{header}". '
'This may be key missmatch or wrong key')
return None
except jwt.exceptions.ExpiredSignatureError:
logger.info(f'Authentication header has expired')
return None
Then, check that it has the expected exp and username parameters. If any of these parameters is missing, that means that the token format, after decoding, is incorrect. This may happen when changing the code in different versions:
# Check expiry is in the token
if 'exp' not in decoded_token:
logger.warning('Token does not have expiry (exp)')
return None
# Check username is in the token
if 'username' not in decoded_token:
logger.warning('Token does not have username')
return None
logger.info('Header successfully validated')
return decoded_token['username']
If everything goes fine, return the username at the end.
Each of the possible problems is logged with a different severity. Most common occurrences are logged with info- level security, as they are not grave. Things such as a format error after the token is decoded may indicate a problem with our encoding process.
Note that we are using a private/public key schema, instead of a symmetric key schema, to encode and decode the tokens. This means that the decoding and encoding keys are different.
In our microservice structure, only the signing authority requires the private key. This increases the security as any key leakage in other services won't be able to retrieve a key capable of signing bearer tokens. We'll need to generate proper private and public keys, though.
To generate a private/public key, run the following command:
$ openssl genrsa -out key.pem 2048
Generating RSA private key, 2048 bit long modulus
.....................+++
.............................+++
Then, to extract the public key, use the following:
$ openssl rsa -in key.pem -outform PEM -pubout -out key.pub
This will generate two files: key.pem and key.pub with a private/public key pair. Reading them in text format will be enough to use them as keys for encoding/decoding the JWT token:
>> with open('private.pem') as fp:
>> .. private_key = fp.read()
>> generate_token_header('peter', private_key)
'Bearer <token>'
Note that, for the tests, we generated a sample key pair that's attached as strings. These keys have been created specifically for this usage and are not used anywhere else. Please do not use them anywhere as they are publicly available in GitHub.