From eeba7c3573f1bfe0078ebb9ba8ff891a45a0b466 Mon Sep 17 00:00:00 2001 From: Domenico Testa Date: Mon, 8 Jun 2020 23:33:18 +0200 Subject: [PATCH] Implementing a signature based authentication. --- README.md | 3 +- app.py | 3 +- client/once.py | 23 ++++- once/delete-served-files/handler.py | 4 +- once/download-and-delete/handler.py | 6 +- once/get-upload-ticket/handler.py | 140 +++++++++++++++------------- once/once_stack.py | 4 +- 7 files changed, 106 insertions(+), 77 deletions(-) diff --git a/README.md b/README.md index 63c723d..737568b 100644 --- a/README.md +++ b/README.md @@ -29,10 +29,9 @@ HERE BE DIAGRAM! [+] Set logs retention policy [+] Deploy custom domain as a nested stack [+] Mask link preview depending on the user agent +[+] Add a robust authentication method -- Add a robust authentication method - Add progressbar to client - - Package application as a click app diff --git a/app.py b/app.py index e3f8e5a..7e7ce83 100644 --- a/app.py +++ b/app.py @@ -7,7 +7,7 @@ from aws_cdk import ( from once.once_stack import OnceStack, CustomDomainStack -USE_CUSTOM_DOMAIN = True +SECRET_KEY = os.getenv('SECRET_KEY', 'ho/KbLqa65F4uKumCOl30SQwWh4hV7BqpuJVl7urq2XuxkvHmBk/QC9l53og0B3X3dSZun7zDYBH6MdOkjj6CQ==') DOMAIN_NAME = os.getenv('DOMAIN_NAME') HOSTED_ZONE_NAME = os.getenv('HOSTED_ZONE_NAME') HOSTED_ZONE_ID = os.getenv('HOSTED_ZONE_ID') @@ -15,6 +15,7 @@ HOSTED_ZONE_ID = os.getenv('HOSTED_ZONE_ID') app = core.App() once = OnceStack(app, 'once', + secret_key=SECRET_KEY, custom_domain=DOMAIN_NAME, hosted_zone_id=HOSTED_ZONE_ID, hosted_zone_name=HOSTED_ZONE_NAME) diff --git a/client/once.py b/client/once.py index b49ff1e..d5b2e94 100644 --- a/client/once.py +++ b/client/once.py @@ -3,9 +3,13 @@ Simple command to share one-time files ''' import os +import base64 +import hashlib +import hmac import json import time -import urllib +from datetime import datetime +from urllib.parse import quote_plus, urljoin import click import requests @@ -13,6 +17,9 @@ from pygments import highlight, lexers, formatters ONCE_API_URL = os.getenv('ONCE_API_URL') +ONCE_SECRET_KEY = base64.b64decode(os.getenv('ONCE_SECRET_KEY', 'ho/KbLqa65F4uKumCOl30SQwWh4hV7BqpuJVl7urq2XuxkvHmBk/QC9l53og0B3X3dSZun7zDYBH6MdOkjj6CQ==')) +ONCE_SIGNATURE_HEADER = os.getenv('ONCE_SIGNATURE_HEADER', 'x-once-signature') +ONCE_TIMESTAMP_FORMAT = os.getenv('ONCE_TIMESTAMP_FORMAT', '%Y%m%d%H%M%S%f') def highlight_json(obj): @@ -29,12 +36,17 @@ def api_req(method: str, url: str, verbose: bool = False, **kwargs): if method not in ['get', 'post']: raise ValueError(f'Unsupported HTTP method "{method}"') - actual_url = f'{ONCE_API_URL}{url}' + actual_url = urljoin(ONCE_API_URL, url) if verbose: print(f'{method.upper()} {actual_url}') - response = getattr(requests, method)(actual_url, **kwargs) + req = requests.Request(method=method, url=actual_url, **kwargs).prepare() + plain_text = req.path_url.encode('utf-8') + hmac_obj = hmac.new(ONCE_SECRET_KEY, msg=plain_text, digestmod=hashlib.sha256) + req.headers[ONCE_SIGNATURE_HEADER] = base64.b64encode(hmac_obj.digest()) + + response = requests.Session().send(req) if verbose: print(f'Server response status: {response.status_code}') @@ -48,7 +60,10 @@ def api_req(method: str, url: str, verbose: bool = False, **kwargs): @click.option('--verbose', '-v', is_flag=True, default=False, help='Enables verbose output.') def share(file: click.File, verbose: bool): entry = api_req('GET', '/', - params={'f': urllib.parse.quote_plus(os.path.basename(file.name))}, + params={ + 'f': quote_plus(os.path.basename(file.name)), + 't': datetime.utcnow().strftime(ONCE_TIMESTAMP_FORMAT) + }, verbose=verbose).json() once_url = entry['once_url'] diff --git a/once/delete-served-files/handler.py b/once/delete-served-files/handler.py index 6aead43..f5ab457 100644 --- a/once/delete-served-files/handler.py +++ b/once/delete-served-files/handler.py @@ -29,8 +29,8 @@ else: def on_event(event, context): - log.info(f'Event received: {event}') - log.info(f'Context is: {context}') + log.debug(f'Event received: {event}') + log.debug(f'Context is: {context}') log.debug(f'Debug mode is {DEBUG}') log.debug(f'Files bucket is "{FILES_BUCKET}"') diff --git a/once/download-and-delete/handler.py b/once/download-and-delete/handler.py index ba8081d..072e9dd 100644 --- a/once/download-and-delete/handler.py +++ b/once/download-and-delete/handler.py @@ -44,8 +44,8 @@ else: def on_event(event, context): - log.info(f'Event received: {event}') - log.info(f'Context is: {context}') + log.debug(f'Event received: {event}') + log.debug(f'Context is: {context}') log.debug(f'Debug mode is {DEBUG}') log.debug(f'Files bucket is "{FILES_BUCKET}"') @@ -58,7 +58,7 @@ def on_event(event, context): TableName=FILES_TABLE_NAME, Key={'id': {'S': entry_id}}) - log.debug(f'This is the GET_ITEM response: {entry}') + log.debug(f'Matched Dynamodb entry: {entry}') if 'Item' not in entry or 'deleted' in entry['Item']: error_message = f'Entry not found: {object_name}' diff --git a/once/get-upload-ticket/handler.py b/once/get-upload-ticket/handler.py index 158f264..f651a0a 100644 --- a/once/get-upload-ticket/handler.py +++ b/once/get-upload-ticket/handler.py @@ -6,8 +6,9 @@ import logging import os import random import string -import urllib +from datetime import datetime, timedelta from typing import Dict +from urllib.parse import quote, quote_plus, unquote_plus, urlencode import boto3 import requests @@ -30,6 +31,11 @@ FILES_BUCKET = os.getenv('FILES_BUCKET') FILES_TABLE_NAME = os.getenv('FILES_TABLE_NAME') S3_REGION_NAME = os.getenv('S3_REGION_NAME', 'eu-west-1') S3_SIGNATURE_VERSION = os.getenv('S3_SIGNATURE_VERSION', 's3v4') +SECRET_KEY = base64.b64decode(os.getenv('SECRET_KEY')) +SIGNATURE_HEADER = os.getenv('SIGNATURE_HEADER', 'x-once-signature') +SIGNATURE_TIME_TOLERANCE = int(os.getenv('SIGNATURE_TIME_TOLERANCE', 5)) +TIMESTAMP_FORMAT_STRING = os.getenv('TIMESTAMP_FORMAT_STRING', '%d%m%Y%H%M%S') +TIMESTAMP_PARAMETER_FORMAT = '%Y%m%d%H%M%S%f' log = logging.getLogger() @@ -47,10 +53,60 @@ class UnauthorizedError(Exception): pass +def create_presigned_post(bucket_name: str, object_name: str, + fields=None, conditions=None, expiration=3600) -> Dict: + ''' + Generate a presigned URL S3 POST request to upload a file + ''' + s3_client = boto3.client('s3', + region_name=S3_REGION_NAME, + config=Config(signature_version=S3_SIGNATURE_VERSION)) + + return s3_client.generate_presigned_post( + bucket_name, object_name, + Fields=fields, + Conditions=conditions, + ExpiresIn=expiration) + + +def validate_signature(event: Dict, secret_key: bytes) -> bool: + canonicalized_url = event['rawPath'] + if 'queryStringParameters' in event: + qs = urlencode(event['queryStringParameters'], quote_via=quote_plus) + canonicalized_url = f'{canonicalized_url}?{qs}' + + plain_text = canonicalized_url.encode('utf-8') + log.debug(f'Plain text: {plain_text}') + + encoded_signature = event['headers'][SIGNATURE_HEADER] + log.debug(f'Received signature: {encoded_signature}') + + signature_value = base64.b64decode(encoded_signature) + + hmac_obj = hmac.new(secret_key, + msg=plain_text, + digestmod=hashlib.sha256) + + calculated_signature = hmac_obj.digest() + return calculated_signature == signature_value + + +def validate_timestamp(timestamp: str, current_time: datetime=None) -> bool: + if current_time is None: + current_time = datetime.utcnow() + + try: + file_loading_time = datetime.strptime(timestamp, TIMESTAMP_PARAMETER_FORMAT) + return current_time - file_loading_time <= timedelta(seconds=SIGNATURE_TIME_TOLERANCE) + except: + log.error(f'Could not validate timestamp {timestamp} according to the format: {TIMESTAMP_PARAMETER_FORMAT}') + return False + + def on_event(event, context): - log.info(f'Event received: {event}') - log.info(f'Context is: {context}') - log.info(f'Requests library version: {requests.__version__}') + log.debug(f'Event received: {event}') + log.debug(f'Context is: {context}') + log.debug(f'Requests library version: {requests.__version__}') log.debug(f'Debug mode is {DEBUG}') log.debug(f'App URL is "{APP_URL}"') @@ -61,17 +117,30 @@ def on_event(event, context): log.debug(f'Pre-signed urls will expire after {EXPIRATION_TIMEOUT} seconds') q = event.get('queryStringParameters', {}) - filename = urllib.parse.unquote_plus(q.get('f')) + filename = unquote_plus(q.get('f')) + timestamp = unquote_plus(q.get('t')) + response_code = 200 response = {} try: if filename is None: raise BadRequestError('Provide a valid value for the `f` query parameter') + if timestamp is None: + raise BadRequestError('Please provide a valid value for the `t` query parameter') + + if not validate_timestamp(timestamp): + log.error('Request timestamp is not valid') + raise UnauthorizedError('Your request cannot be authorized') + + if not validate_signature(event, SECRET_KEY): + log.error('Request signature is not valid') + raise UnauthorizedError('Your request cannot be authorized') + domain = string.ascii_uppercase + string.ascii_lowercase + string.digits entry_id = ''.join(random.choice(domain) for _ in range(6)) object_name = f'{entry_id}/{filename}' - response['once_url'] = f'{APP_URL}{entry_id}/{urllib.parse.quote(filename)}' + response['once_url'] = f'{APP_URL}{entry_id}/{quote(filename)}' dynamodb = boto3.client('dynamodb') dynamodb.put_item( @@ -89,10 +158,8 @@ def on_event(event, context): object_name=object_name, expiration=EXPIRATION_TIMEOUT) - log.debug(f'Presigned-Post response: {presigned_post}') - - # Long life and prosperity! log.info(f'Authorized upload request for {object_name}') + log.debug(f'Presigned-Post response: {presigned_post}') response['presigned_post'] = presigned_post except BadRequestError as e: response_code = 400 @@ -108,58 +175,3 @@ def on_event(event, context): 'statusCode': response_code, 'body': json.dumps(response) } - - - -# def validate_request(event: Dict, secret_key: str) -> bool: -# ''' -# Validates the HMAC(SHA256) signature against the given `request`. -# ''' - -# # discard any url prefix before '/v1/' -# path = event['rawPath'] -# canonicalized_url = path[path.find('/v1/'):] - -# if 'queryStringParameters' in event: -# qs = urlencode(event['queryStringParameters'], quote_via=quote_plus) -# canonicalized_url = f'{canonicalized_url}?{qs}' - -# plain_text = canonicalized_url.encode('utf-8') -# log.debug(f'Plain text: {plain_text}') - -# encoded_signature = event['headers'][HMAC_SIGNATURE_HEADER] -# log.debug(f'Received signature: {encoded_signature}') - -# signature_value = base64.b64decode(encoded_signature) - -# hmac_obj = hmac.new(base64.b64decode(secret_key), -# msg=plain_text, -# digestmod=hashlib.sha256) - -# calculated_signature = hmac_obj.digest() -# return calculated_signature == signature_value - - -def create_presigned_post(bucket_name: str, object_name: str, - fields=None, conditions=None, expiration=3600): - """Generate a presigned URL S3 POST request to upload a file - - :param bucket_name: string - :param object_name: string - :param fields: Dictionary of prefilled form fields - :param conditions: List of conditions to include in the policy - :param expiration: Time in seconds for the presigned URL to remain valid - :return: Dictionary with the following keys: - url: URL to post to - fields: Dictionary of form fields and values to submit with the POST - :return: None if error. - """ - s3_client = boto3.client('s3', - region_name=S3_REGION_NAME, - config=Config(signature_version=S3_SIGNATURE_VERSION)) - - return s3_client.generate_presigned_post( - bucket_name, object_name, - Fields=fields, - Conditions=conditions, - ExpiresIn=expiration) diff --git a/once/once_stack.py b/once/once_stack.py index 9c67219..600213a 100644 --- a/once/once_stack.py +++ b/once/once_stack.py @@ -77,6 +77,7 @@ class CustomDomainStack(cfn.NestedStack): class OnceStack(core.Stack): def __init__(self, scope: core.Construct, id: str, + secret_key: str, custom_domain: Optional[str] = None, hosted_zone_id: Optional[str] = None, hosted_zone_name: Optional[str] = None, @@ -113,7 +114,8 @@ class OnceStack(core.Stack): environment={ 'APP_URL': api_url, 'FILES_TABLE_NAME': self.files_table.table_name, - 'FILES_BUCKET': self.files_bucket.bucket_name + 'FILES_BUCKET': self.files_bucket.bucket_name, + 'SECRET_KEY': secret_key }) self.files_bucket.grant_put(self.get_upload_ticket_function)