astamuse Lab

astamuse Labとは、アスタミューゼのエンジニアとデザイナーのブログです。アスタミューゼの事業・サービスを支えている知識と舞台裏の今を発信しています。

データベースを使わずに、有効期限付きURLを生成したい

自称データエンジニアのaranです。 月日の流れは早いもので、去年の9月以来の再登板になります

私ごとですが
先月に健康診断があり、試しに1日1食の生活を3ヶ月ほど実施してみました。

ストイックに毎日続けるのは無理なので、以下の条件下で試しました

  • 1日1食だが、好きなだけ食べる

  • 土日は食事制限しない

  • ガマンできない時はチョコレート(一口分)を食べてよい。ただし3口分まで

  • みんなと食事するときは、お昼を食べてよい

開始当初は、お腹がなりまくっていたのですが、しばらくするとお腹はならなくなります。 また、一番の体の変化は、昼以降に眠くならないことです。
(前日の睡眠時間次第のところはありますが)

友人によく、朝食べないと体にエネルギーがなく、パフォーマンスが...的なことを言われました。
ただ、私に限ったことですが、まったく問題なかったです。
むしろ、昼以降に眠くならず、集中力が続くので、作業効率はUPしたような気がします

健康診断の結果は改善した項目があり、私にとって1日3食は食べ過ぎなんだなって思いました。

はじめに

わたしのここ最近の業務はデータ整備で
構造化データをパースし、データ整形してデータ登録する いわゆるETL作業を行っております。

楽しく作業している傍ら、データソース元は公開データでかつ、閉じた環境で作業していることもあり
セキュリティに無頓着になっています。。

こんな状況の中とある案件で
簡単に改ざんできないような有効期限付きURLが必要になり
久しぶりにセキュリティを意識することになりました。

今回は、有効期限付きURLをどう生成したかについてお話したいと思います。

有効期限付きURLについて

有効期限付きURLが必要の際
URLにパラメータを入れて、そのURLと有効期限をデータベースに登録する方法を
主に採用すると思います。

当案件の要件(制約)は、データベース利用せずに有効期限付きURLを生成することで
これって賢人が既にやっているだろうと思い、ちょっと調べていたら
賢人のブログを発見しました。

このブログのサンプルソースは PHP なのですが
当案件では、python(ver 3.8)を利用しているので、一部移植してみました。

手始めに

今回は参考サイトの手法以外も試しています。

AES方式を使って有効期限付きURLを生成

まず、AES方式を使って有効期限付きURLを生成してみます。

簡単な手順は以下になります

  1. 秘密鍵を事前に生成する

  2. 有効期限を暗号化

  3. 暗号化した有効期限をURLパラメータ化

  4. 受け取ったパラメータを復号化

  5. 有効期限のチェック

暗号化・復号化には PyCryptodome ライブラリを利用しました。

pycryptodome.readthedocs.io

最初は PyCrypto ライブラリを利用していましたが
ドキュメントを読むと2013年10月以降アップデートが止まっているので、
PyCryptoの利用は控えました。

尚、暗号化・復号化の処理はこちらのソースを参考しています

有効期限付きURL生成するサンプルコードです。

サンプルコード

import datetime
import base64
from urllib import parse

from Crypto import Random
from Crypto.Cipher import AES
from Crypto.Util import Padding

SECRET_KEY: bytes = b'秘密鍵を生成し、適宜管理して下さい'
BASE_URL: str = 'http://example.com'


def generate_url() -> str:
    def __encrypt(raw_data: str) -> str:
        iv: bytes = Random.get_random_bytes(AES.block_size)
        cipher = AES.new(SECRET_KEY, AES.MODE_CBC, iv)
        pad_data = Padding.pad(raw_data.encode('utf-8'), AES.block_size, 'pkcs7')
        return base64.b64encode(iv + cipher.encrypt(pad_data)).decode()

    # 有効期限の設定
    expiry: datetime.datetime = datetime.datetime.now() + datetime.timedelta(minutes=5)
    expiry_string: str = str(int(expiry.timestamp()))
    # 有効期限の暗号化
    param_expiry: str = __encrypt(expiry_string)
    # 有効期限付きURL生成
    return f"{BASE_URL}?expiry={param_expiry}"

こちらを実行すると、以下のように有効期限つきURLを生成できます

>>> generate_url()
https://example.com?expiry=N934g0hrNZp4weCvIpYsTw1psEgGIwW6T4NMSVjDI6E=

生成した有効期限付きURLをパースしてみます。

サンプルコード

# import文は上記と同じなので、省略

def validate_url(url: str):
    def __parse_url(url: str) -> str:
        qs: str = parse.urlparse(url).query
        q: dict = parse.parse_qs(qs)

        expiry_from_request: str = q['expiry'][0]
        return expiry_from_request

    def __decrypt(enc_data: str) -> str:
        enc: bytes = base64.b64decode(enc_data)
        iv: bytes = enc[:AES.block_size]
        cipher = AES.new(SECRET_KEY, AES.MODE_CBC, iv)
        unpad_data: bytes = Padding.unpad(cipher.decrypt(enc[AES.block_size:]), AES.block_size, 'pkcs7')
        return unpad_data.decode('utf-8')

    def __now_ts() -> int:
        return int(datetime.datetime.now().timestamp())

    # URLパラメータのパース
    expiry_from_request: str = __parse_url(url)
    # 有効期限の復号化
    expiry_ts: int = int(__decrypt(expiry_from_request))

    if expiry_ts < __now_ts():
        print('期限切れURL')

有効期限付きURLをチェックしてみます。

>>> url: str = 'https://example.com?expiry=N934g0hrNZp4weCvIpYsTw1psEgGIwW6T4NMSVjDI6E='
>>> validate_url(url)
期限切れURL

有効期限の時間経過後にチェックしたところ、期限切れを確認できました。

ハッシュ(HMAC)を使って有効期限付きURLを生成

次にブログにある
ハッシュ(HMAC)を使って、有効期限付きURLを生成したいと思います。

簡単な手順は以下になります

  1. 秘密鍵を事前に生成する

  2. 有効期限をシリアライズ

  3. 共有鍵を生成する

  4. 導出鍵を生成する

  5. 有効期限、共有鍵、導出鍵をURLパラメータ化

  6. 受け取ったパラメータを復号化

  7. 改ざんチェック

  8. 有効期限をデシリアライズ

  9. 有効期限チェック

尚、ブログでPythonにはない関数を利用していますので、完全な移植ではありませんし、
一部処理を簡素化しています。

サンプルコード

有効期限付きURLを生成するサンプルコードです。

import base64
import datetime
import hashlib
import hmac
import secrets
import pickle
from typing import Tuple
from urllib import parse

def generate_url() -> str:
    def __generate_salt() -> bytes:
        return secrets.token_hex(16).encode('utf-8')

    def __generate_context(expiry: str) -> bytes:
        return pickle.dumps((base_url, expiry))

    def __generate_derived_key(salt: bytes, context: bytes) -> bytes:
        prk: bytes = hmac.new(SECRET_KEY, salt, hashlib.sha256).digest()
        return hmac.new(prk, context, hashlib.sha256).digest()

    # 有効期限の設定
    expiry: datetime.datetime = datetime.datetime.now() + datetime.timedelta(minutes=5)
    expiry_string: str = str(int(expiry.timestamp()))
    # コンテクスト(URL, 有効期限)
    context: bytes = __generate_context(expiry=expiry_string)
    # 公開鍵の生成
    salt: bytes = __generate_salt()
    # 導出鍵
    derived_key: bytes = __generate_derived_key(salt=salt, context=context)

    param_key1: str = base64.b64encode(derived_key).decode()
    param_key2: str = base64.b64encode(salt).decode()
    param_context: str = base64.b64encode(context).decode()
    # 有効期限付きURL生成
    return f"{BASE_URL}?key1={param_key1}&key2={param_key2}&context={param_context}"

生成した有効期限付きURLをチェックするサンプルコードです。

サンプルコード

# import文は上記と同じため、省略

def validate_url(url: str):
    def __parse_url(url: str) -> Tuple[bytes, bytes, bytes]:
        qs: str = parse.urlparse(url).query
        q: dict = parse.parse_qs(qs)

        # TODO: デコード時にエラーが発生する場合がありますが、今回はその対策を省略しています

        # 送られてきた導出鍵
        encoding_derived_key = q['key1'][0]
        derived_key_from_request: bytes = base64.b64decode(encoding_derived_key)

        # 送られてきた共有鍵
        encoding_salt = q['key2'][0]
        salt_from_request: bytes = base64.b64decode(encoding_salt)

        # 送られてきたコンテクスト(URL, 有効期限)
        encoding_context = q['context'][0]
        context_from_request: bytes = base64.b64decode(encoding_context)

        return (derived_key_from_request, salt_from_request, context_from_request)

    def __validate_request(derived_key_from_request: bytes, salt_from_request: bytes, context_from_request: bytes) -> bool:
        validate_prk: bytes = hmac.new(SECRET_KEY, salt_from_request, hashlib.sha256).digest()
        validate_derived_key: bytes = hmac.new(validate_prk, context_from_request, hashlib.sha256).digest()

        return hmac.compare_digest(derived_key_from_request, validate_derived_key)

    def __now_ts() -> int:
        return int(datetime.datetime.now().timestamp())

    def __extracted_expiry_ts(context_from_request: bytes) -> int:
        deserialize_contents: Tuple['str', 'str'] = pickle.loads(context_from_request)
        return int(deserialize_contents[1])

    # リクエストパラメータのパース
    derived_key_from_request, salt_from_request, context_from_request = __parse_url(url)

    if not __validate_request(derived_key_from_request, salt_from_request, context_from_request):
        raise Exception("不正なリクエスト")

    # 有効期限の抽出
    expiry_ts: int = __extracted_expiry_ts(context_from_request)
    if expiry_ts < __now_ts():
        print('期限切れURL')

最後に

参考にしたブログに書いてある通り、データベースを使わないので、大量の有効期限付きURLを発行しても
アクセス管理ができることがメリットだとわかりました。
要件次第になりますが、選択肢のひとつになるかと思います。

アスタミューゼでは、エンジニア・デザイナーを募集中です。 ご興味のある方は遠慮なく採用サイトからご応募願います。是非、お待ちしています。

Copyright © astamuse company, ltd. all rights reserved.