Implementing a signature based authentication.

This commit is contained in:
2020-06-08 23:33:18 +02:00
parent a465e4ddd3
commit eeba7c3573
7 changed files with 106 additions and 77 deletions

View File

@@ -29,10 +29,9 @@ HERE BE DIAGRAM!
[+] Set logs retention policy [+] Set logs retention policy
[+] Deploy custom domain as a nested stack [+] Deploy custom domain as a nested stack
[+] Mask link preview depending on the user agent [+] Mask link preview depending on the user agent
[+] Add a robust authentication method
- Add a robust authentication method
- Add progressbar to client - Add progressbar to client
- Package application as a click app - Package application as a click app

3
app.py
View File

@@ -7,7 +7,7 @@ from aws_cdk import (
from once.once_stack import OnceStack, CustomDomainStack 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') DOMAIN_NAME = os.getenv('DOMAIN_NAME')
HOSTED_ZONE_NAME = os.getenv('HOSTED_ZONE_NAME') HOSTED_ZONE_NAME = os.getenv('HOSTED_ZONE_NAME')
HOSTED_ZONE_ID = os.getenv('HOSTED_ZONE_ID') HOSTED_ZONE_ID = os.getenv('HOSTED_ZONE_ID')
@@ -15,6 +15,7 @@ HOSTED_ZONE_ID = os.getenv('HOSTED_ZONE_ID')
app = core.App() app = core.App()
once = OnceStack(app, 'once', once = OnceStack(app, 'once',
secret_key=SECRET_KEY,
custom_domain=DOMAIN_NAME, custom_domain=DOMAIN_NAME,
hosted_zone_id=HOSTED_ZONE_ID, hosted_zone_id=HOSTED_ZONE_ID,
hosted_zone_name=HOSTED_ZONE_NAME) hosted_zone_name=HOSTED_ZONE_NAME)

View File

@@ -3,9 +3,13 @@ Simple command to share one-time files
''' '''
import os import os
import base64
import hashlib
import hmac
import json import json
import time import time
import urllib from datetime import datetime
from urllib.parse import quote_plus, urljoin
import click import click
import requests import requests
@@ -13,6 +17,9 @@ from pygments import highlight, lexers, formatters
ONCE_API_URL = os.getenv('ONCE_API_URL') 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): 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']: if method not in ['get', 'post']:
raise ValueError(f'Unsupported HTTP method "{method}"') raise ValueError(f'Unsupported HTTP method "{method}"')
actual_url = f'{ONCE_API_URL}{url}' actual_url = urljoin(ONCE_API_URL, url)
if verbose: if verbose:
print(f'{method.upper()} {actual_url}') 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: if verbose:
print(f'Server response status: {response.status_code}') 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.') @click.option('--verbose', '-v', is_flag=True, default=False, help='Enables verbose output.')
def share(file: click.File, verbose: bool): def share(file: click.File, verbose: bool):
entry = api_req('GET', '/', 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() verbose=verbose).json()
once_url = entry['once_url'] once_url = entry['once_url']

View File

@@ -29,8 +29,8 @@ else:
def on_event(event, context): def on_event(event, context):
log.info(f'Event received: {event}') log.debug(f'Event received: {event}')
log.info(f'Context is: {context}') log.debug(f'Context is: {context}')
log.debug(f'Debug mode is {DEBUG}') log.debug(f'Debug mode is {DEBUG}')
log.debug(f'Files bucket is "{FILES_BUCKET}"') log.debug(f'Files bucket is "{FILES_BUCKET}"')

View File

@@ -44,8 +44,8 @@ else:
def on_event(event, context): def on_event(event, context):
log.info(f'Event received: {event}') log.debug(f'Event received: {event}')
log.info(f'Context is: {context}') log.debug(f'Context is: {context}')
log.debug(f'Debug mode is {DEBUG}') log.debug(f'Debug mode is {DEBUG}')
log.debug(f'Files bucket is "{FILES_BUCKET}"') log.debug(f'Files bucket is "{FILES_BUCKET}"')
@@ -58,7 +58,7 @@ def on_event(event, context):
TableName=FILES_TABLE_NAME, TableName=FILES_TABLE_NAME,
Key={'id': {'S': entry_id}}) 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']: if 'Item' not in entry or 'deleted' in entry['Item']:
error_message = f'Entry not found: {object_name}' error_message = f'Entry not found: {object_name}'

View File

@@ -6,8 +6,9 @@ import logging
import os import os
import random import random
import string import string
import urllib from datetime import datetime, timedelta
from typing import Dict from typing import Dict
from urllib.parse import quote, quote_plus, unquote_plus, urlencode
import boto3 import boto3
import requests import requests
@@ -30,6 +31,11 @@ FILES_BUCKET = os.getenv('FILES_BUCKET')
FILES_TABLE_NAME = os.getenv('FILES_TABLE_NAME') FILES_TABLE_NAME = os.getenv('FILES_TABLE_NAME')
S3_REGION_NAME = os.getenv('S3_REGION_NAME', 'eu-west-1') S3_REGION_NAME = os.getenv('S3_REGION_NAME', 'eu-west-1')
S3_SIGNATURE_VERSION = os.getenv('S3_SIGNATURE_VERSION', 's3v4') 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() log = logging.getLogger()
@@ -47,10 +53,60 @@ class UnauthorizedError(Exception):
pass 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): def on_event(event, context):
log.info(f'Event received: {event}') log.debug(f'Event received: {event}')
log.info(f'Context is: {context}') log.debug(f'Context is: {context}')
log.info(f'Requests library version: {requests.__version__}') log.debug(f'Requests library version: {requests.__version__}')
log.debug(f'Debug mode is {DEBUG}') log.debug(f'Debug mode is {DEBUG}')
log.debug(f'App URL is "{APP_URL}"') 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') log.debug(f'Pre-signed urls will expire after {EXPIRATION_TIMEOUT} seconds')
q = event.get('queryStringParameters', {}) 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_code = 200
response = {} response = {}
try: try:
if filename is None: if filename is None:
raise BadRequestError('Provide a valid value for the `f` query parameter') 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 domain = string.ascii_uppercase + string.ascii_lowercase + string.digits
entry_id = ''.join(random.choice(domain) for _ in range(6)) entry_id = ''.join(random.choice(domain) for _ in range(6))
object_name = f'{entry_id}/{filename}' 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 = boto3.client('dynamodb')
dynamodb.put_item( dynamodb.put_item(
@@ -89,10 +158,8 @@ def on_event(event, context):
object_name=object_name, object_name=object_name,
expiration=EXPIRATION_TIMEOUT) 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.info(f'Authorized upload request for {object_name}')
log.debug(f'Presigned-Post response: {presigned_post}')
response['presigned_post'] = presigned_post response['presigned_post'] = presigned_post
except BadRequestError as e: except BadRequestError as e:
response_code = 400 response_code = 400
@@ -108,58 +175,3 @@ def on_event(event, context):
'statusCode': response_code, 'statusCode': response_code,
'body': json.dumps(response) '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)

View File

@@ -77,6 +77,7 @@ class CustomDomainStack(cfn.NestedStack):
class OnceStack(core.Stack): class OnceStack(core.Stack):
def __init__(self, scope: core.Construct, id: str, def __init__(self, scope: core.Construct, id: str,
secret_key: str,
custom_domain: Optional[str] = None, custom_domain: Optional[str] = None,
hosted_zone_id: Optional[str] = None, hosted_zone_id: Optional[str] = None,
hosted_zone_name: Optional[str] = None, hosted_zone_name: Optional[str] = None,
@@ -113,7 +114,8 @@ class OnceStack(core.Stack):
environment={ environment={
'APP_URL': api_url, 'APP_URL': api_url,
'FILES_TABLE_NAME': self.files_table.table_name, '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) self.files_bucket.grant_put(self.get_upload_ticket_function)