Skip to content

Commit

Permalink
Merge pull request #265 from uploadcare/feature/264-uuid_signing
Browse files Browse the repository at this point in the history
Generating wildcard ACL and URL tokens for Akamai
  • Loading branch information
evgkirov committed Nov 13, 2023
2 parents 09604ad + dcbd869 commit c897f7d
Show file tree
Hide file tree
Showing 5 changed files with 297 additions and 64 deletions.
29 changes: 21 additions & 8 deletions HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,32 +10,45 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

Summary of this update:

1. Added support for the `save_in_group` parameter in [multipage conversion](https://uploadcare.com/docs/transformations/document-conversion/#multipage-conversion);
2. Implemented the [AWS Rekognition Moderation](https://uploadcare.com/docs/unsafe-content/) addon API;
3. Added `signed_uploads` setting for Django projects;
4. Various bug fixes.
1. Added support for the `save_in_group` parameter in [multipage conversion](https://uploadcare.com/docs/transformations/document-conversion/#multipage-conversion) (#258);
2. Implemented the [AWS Rekognition Moderation](https://uploadcare.com/docs/unsafe-content/) addon API (#260);
3. Added `signed_uploads` setting for Django projects (#262);
4. Secure URL generation improvements (#263 & #264);
5. Various bug fixes.

There are no breaking changes in this release.

### Added

- For `Uploadcare`:
- Added a type for the `event` parameter of the `create_webhook` and `update_webhook` methods.
- Added the `generate_upload_signature` method. This shortcut could be useful for signed uploads from your website's frontend, where the signature needs to be passed outside of your website's Python part (e.g., for the uploading widget).
- Added `generate_secure_url_token` method. Similar to `generate_secure_url`, it returns only a token, not the full URL.
- Added an optional `wildcard` parameter to the `generate_secure_url` method.

- For `File`:
- Added the `save_in_group` parameter to the `convert` and `convert_document` methods. It defaults to `False`. When set to `True`, multi-page documents will additionally be saved as a file group.
- Added the `get_converted_document_group` method. It returns a `FileGroup` instance for converted multi-page documents.

- For `DocumentConvertAPI`:
- Added the `retrieve` method, which corresponds to the [`GET /convert/document/:uuid/`](https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/Conversion/operation/documentConvertInfo) API endpoint.

- For `Uploadcare`:
- Added type for the `event` parameter of the `create_webhook` and `update_webhook` methods.
- Added the `generate_upload_signature` method. This shortcut could be useful for signed uploads from your website's frontend, where the signature needs to be passed outside of your website's Python part (e.g., for the uploading widget).

- For `AddonsAPI` / `AddonLabels`:
- Added support for the [Unsafe content detection](https://uploadcare.com/docs/unsafe-content/) addon (`AddonLabels.AWS_MODERATION_LABELS`).

- For Django integration:
- Added the `signed_uploads` setting for Django projects. When enabled, this setting exposes the generated signature to the uploading widget.

- For `AkamaiSecureUrlBuilderWithAclToken`:
- Added `get_token` method.
- Added an optional `wildcard` parameter to the `build` method.

- Introduced `AkamaiSecureUrlBuilderWithUrlToken` class.

### Changed

- `AkamaiSecureUrlBuilder` has been renamed to `AkamaiSecureUrlBuilderWithAclToken`. It is still available under the old name and works as before, but it will issue a deprecation warning when used.

### Fixed

- For `AddonsAPI` / `AddonExecutionParams`:
Expand Down
43 changes: 39 additions & 4 deletions docs/core_api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -399,12 +399,15 @@ Secure delivery

You can use your own custom domain and CDN provider for deliver files with authenticated URLs (see `original documentation`_).

Generate secure for file::
Generate secure URL for file::

from pyuploadcare import Uploadcare
from pyuploadcare.secure_url import AkamaiSecureUrlBuilder
from pyuploadcare.secure_url import AkamaiSecureUrlBuilderWithAclToken

secure_url_bulder = AkamaiSecureUrlBuilder("your cdn>", "<your secret for token generation>")
secure_url_bulder = AkamaiSecureUrlBuilderWithAclToken(
"<your cdn>",
"<your secret for token generation>"
)

uploadcare = Uploadcare(
public_key='<your public key>',
Expand All @@ -414,12 +417,44 @@ Generate secure for file::

secure_url = uploadcare.generate_secure_url('52da3bfc-7cd8-4861-8b05-126fef7a6994')

Generate secure for file with transformations::
Generate just the token::

token = uploadcare.get_secure_url_token('52da3bfc-7cd8-4861-8b05-126fef7a6994')

Generate secure URL for file with transformations::

secure_url = uploadcare.generate_secure_url(
'52da3bfc-7cd8-4861-8b05-126fef7a6994/-/resize/640x/other/transformations/'
)

Generate secure URL for file, with the same signature valid for its transformations::

secure_url = uploadcare.generate_secure_url(
'52da3bfc-7cd8-4861-8b05-126fef7a6994',
wildcard=True
)

Generate secure URL for file by its URL (please notice the usage of a different builder class)::

from pyuploadcare import Uploadcare
from pyuploadcare.secure_url import AkamaiSecureUrlBuilderWithUrlToken

secure_url_bulder = AkamaiSecureUrlBuilderWithUrlToken(
"<your cdn>",
"<your secret for token generation>"
)

uploadcare = Uploadcare(
public_key='<your public key>',
secret_key='<your private key>',
secure_url_builder=secure_url_bulder,
)

secure_url = uploadcare.generate_secure_url(
'https://cdn.yourdomain.com/52da3bfc-7cd8-4861-8b05-126fef7a6994/'
)


Useful links
------------

Expand Down
18 changes: 16 additions & 2 deletions pyuploadcare/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -825,12 +825,26 @@ def generate_upload_signature(self) -> Tuple[int, str]:
)
return expire, signature

def generate_secure_url(self, uuid: Union[str, UUID]) -> str:
def generate_secure_url(
self, uuid: Union[str, UUID], wildcard: bool = False
) -> str:
"""Generate authenticated URL."""
if isinstance(uuid, UUID):
uuid = str(uuid)

if not self.secure_url_builder:
raise ValueError("secure_url_builder must be set")

return self.secure_url_builder.build(uuid)
return self.secure_url_builder.build(uuid, wildcard=wildcard)

def generate_secure_url_token(
self, uuid: Union[str, UUID], wildcard: bool = False
) -> str:
"""Generate token for authenticated URL."""
if isinstance(uuid, UUID):
uuid = str(uuid)

if not self.secure_url_builder:
raise ValueError("secure_url_builder must be set")

return self.secure_url_builder.get_token(uuid, wildcard=wildcard)
153 changes: 110 additions & 43 deletions pyuploadcare/secure_url.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,30 @@
import hashlib
import hmac
import time
import warnings
from abc import ABC, abstractmethod
from typing import Optional


class BaseSecureUrlBuilder(ABC):
@abstractmethod
def build(self, uuid: str) -> str:
def build(self, uuid: str, wildcard: bool = False) -> str:
raise NotImplementedError

def get_token(self, uuid: str, wildcard: bool = False) -> str:
raise NotImplementedError(
f"{self.__class__} doesn't provide get_token()"
)


class AkamaiSecureUrlBuilder(BaseSecureUrlBuilder):
class BaseAkamaiSecureUrlBuilder(BaseSecureUrlBuilder):
"""Akamai secure url builder.
See https://uploadcare.com/docs/security/secure_delivery/
for more details.
"""

template = "https://{cdn}/{uuid}/?token={token}"
template = "{base}?token={token}"
field_delimeter = "~"

def __init__(
Expand All @@ -34,63 +40,124 @@ def __init__(
self.window = window
self.hash_algo = hash_algo

def build(self, uuid: str) -> str:
uuid = uuid.lstrip("/").rstrip("/")
def build(self, uuid: str, wildcard: bool = False) -> str:
uuid_or_url = self._format_uuid_or_url(uuid)
token = self.get_token(uuid_or_url, wildcard=wildcard)
secure_url = self._build_url(uuid_or_url, token)
return secure_url

def get_token(self, uuid: str, wildcard: bool = False) -> str:
uuid_or_url = self._format_uuid_or_url(uuid)
expire = self._build_expire_time()
acl = self._format_acl(uuid_or_url, wildcard=wildcard)
signature = self._build_signature(uuid_or_url, expire, acl)
token = self._build_token(expire, acl, signature)
return token

acl = self._format_acl(uuid)

signature = self._build_signature(expire, acl)

secure_url = self._build_url(uuid, expire, acl, signature)
return secure_url
def _build_expire_time(self) -> int:
return int(time.time()) + self.window

def _build_url(
self,
uuid: str,
expire: int,
acl: str,
signature: str,
def _build_signature(
self, uuid_or_url: str, expire: int, acl: Optional[str]
) -> str:
req_parameters = [

hash_source = [
f"exp={expire}",
f"acl={acl}",
f"hmac={signature}",
f"acl={acl}" if acl else f"url={uuid_or_url}",
]

token = self.field_delimeter.join(req_parameters)
signature = hmac.new(
binascii.a2b_hex(self.secret_key.encode()),
self.field_delimeter.join(hash_source).encode(),
self.hash_algo,
).hexdigest()

return self.template.format(
cdn=self.cdn_url,
uuid=uuid,
token=token,
)
return signature

def _build_token(self, expire: int, acl: Optional[str], signature: str):

token_parts = [
f"exp={expire}",
f"acl={acl}",
f"acl={acl}" if acl else None,
f"hmac={signature}",
]
return self.field_delimeter.join(token_parts)

def _format_acl(self, uuid: str) -> str:
return f"/{uuid}/"
return self.field_delimeter.join(
part for part in token_parts if part is not None
)

def _build_expire_time(self) -> int:
return int(time.time()) + self.window
@abstractmethod
def _build_base_url(self, uuid_or_url: str):
raise NotImplementedError

def _build_signature(self, expire: int, acl: str) -> str:
hash_source = [
f"exp={expire}",
f"acl={acl}",
]
def _build_url(
self,
uuid_or_url: str,
token: str,
) -> str:
base_url = self._build_base_url(uuid_or_url)
return self.template.format(
base=base_url,
token=token,
)

signature = hmac.new(
binascii.a2b_hex(self.secret_key.encode()),
self.field_delimeter.join(hash_source).encode(),
self.hash_algo,
).hexdigest()
@abstractmethod
def _format_acl(self, uuid_or_url: str, wildcard: bool) -> Optional[str]:
raise NotImplementedError

return signature
@abstractmethod
def _format_uuid_or_url(self, uuid_or_url: str) -> str:
raise NotImplementedError


class AkamaiSecureUrlBuilderWithAclToken(BaseAkamaiSecureUrlBuilder):
base_template = "https://{cdn}/{uuid}/"

def _build_base_url(self, uuid_or_url: str):
return self.base_template.format(cdn=self.cdn_url, uuid=uuid_or_url)

def _format_acl(self, uuid_or_url: str, wildcard: bool) -> str:
if wildcard:
return f"/{uuid_or_url}/*"
return f"/{uuid_or_url}/"

def _format_uuid_or_url(self, uuid_or_url: str) -> str:
return uuid_or_url.lstrip("/").rstrip("/")


class AkamaiSecureUrlBuilderWithUrlToken(BaseAkamaiSecureUrlBuilder):
def _build_base_url(self, uuid_or_url: str):
return uuid_or_url

def _format_acl(self, uuid_or_url: str, wildcard: bool) -> None:
if wildcard:
raise ValueError(
"Wildcards are not supported in AkamaiSecureUrlBuilderWithUrlToken."
)
return None

def _format_uuid_or_url(self, uuid_or_url: str) -> str:
if "://" not in uuid_or_url:
raise ValueError(f"{uuid_or_url} doesn't look like a URL")
return uuid_or_url


class AkamaiSecureUrlBuilder(AkamaiSecureUrlBuilderWithAclToken):
def __init__(
self,
cdn_url: str,
secret_key: str,
window: int = 300,
hash_algo=hashlib.sha256,
):
warnings.warn(
"AkamaiSecureUrlBuilder class was renamed to AkamaiSecureUrlBuilderWithAclToken",
DeprecationWarning,
stacklevel=2,
)
super().__init__(
cdn_url=cdn_url,
secret_key=secret_key,
window=window,
hash_algo=hash_algo,
)
Loading

0 comments on commit c897f7d

Please sign in to comment.