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
[+] 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

3
app.py
View File

@@ -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)

View File

@@ -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']

View File

@@ -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}"')

View File

@@ -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}'

View File

@@ -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)

View File

@@ -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)