Implementing a signature based authentication.
This commit is contained in:
@@ -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
3
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)
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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}"')
|
||||
|
||||
|
||||
@@ -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}'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user