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