🆔

[Django] cuidを使って共有リンクを推測しづらくする

2024/05/02に公開

背景

あるアイテムを誰かに共有するためのリンクを作る時、
https://sample.com?item_id=1
のように、連番のIDを含むリンクだと非公開なアイテムを推測される恐れがあります.

uuidのような非連続なIDを利用することでこの問題を回避できます.
https://sample.com?item_uuid=gsahpri739b..

対応

どのidを使うか検討

非連続なidにも種類があるため、ユースケースに沿ったものを選択します。

ID名 メリット デメリット
uuid
ulid
cuid
nanoid

https://zenn.dev/reiwatravel/articles/9ce1050bf8509b

モデルにcuidを追加

https://github.com/paralleldrive/cuid2

pip install cuid2
from cuid2 import cuid_wrapper


CUID_GENERATOR = cuid_wrapper()

class Item(models.Model):
    uid = models.CharField(
        max_length=24,
        unique=True,
        editable=False,
        default=CUID_GENERATOR,
    )

defaultにはgeneratorを指定する必要があることに注意が必要です。
もし

# 間違った例
default=CUID_GENERATOR()

とした場合、固定のCUIDがdefault値になり、Unique制約に違反してエラーになります。

マイグレーションファイルの修正

https://docs.djangoproject.com/en/5.0/howto/writing-migrations/#migrations-that-add-unique-fields

データベースにはアイテムが登録された状態で後からユニーク制約のあるカラムを追加するには、少しコツが必要でした。上記のドキュメントに詳しく書いてあります。

3つのマイグレーションファイルが必要になります。

  1. cuidフィールドをnull=Trueとして追加
  2. 既存のcuidフィールドにcuidを挿入する
  3. cuidフィールドをunique=Trueに変更する

1つ目

docker compose exec api python manage.py makemigrations
from django.db import migrations, models
from cuid2 import cuid_wrapper


CUID_GENERATOR = cuid_wrapper()

class Migration(migrations.Migration):
    dependencies = [
        ("api", "0008"),
    ]

    operations = [
        migrations.AddField(
            model_name="item",
            name="cuid",
            field=models.CharField(
                max_length=24,
                null=True,
                editable=False,
                default=CUID_GENERATOR,
            ),
        ),
    ]

2つ目
最初に空のMigrationファイルを生成します.

docker compose exec api python manage.py makemigrations api --empty
from django.db import migrations
from cuid2 import cuid_wrapper

CUID_GENERATOR = cuid_wrapper()

def gen_cuid(apps, schema_editor):
    Item = apps.get_model("api", "Item")
    for row in Item.objects.all():
        row.cuid = CUID_GENERATOR()
        row.save(update_fields=["cuid"])


class Migration(migrations.Migration):
    dependencies = [
        ("api", "0009"),
    ]

    operations = [
        migrations.RunPython(gen_cuid, reverse_code=migrations.RunPython.noop),
    ]

3つ目

docker compose exec api python manage.py makemigrations api --empty
from django.db import migrations, models
from cuid2 import cuid_wrapper


CUID_GENERATOR = cuid_wrapper()

class Migration(migrations.Migration):
    dependencies = [
        ("api", "0010"),
    ]

    operations = [
        migrations.AlterField(
            model_name="item",
            name="cuid",
            field=models.CharField(
                max_length=24,
                unique=True,
                editable=False,
                default=CUID_GENERATOR,
            ),
        ),
    ]

マイグレートを実行して、既存のアイテムにcuidが割り当てられていることを確認しましょう。

python manage.py migrate

GraphQLクエリに反映

class Query(graphene.ObjectType):
    item = graphene.Field(
        SharedItemType,
        cuid=graphene.String(),
    )

    def resolve_item(self, info, cuid):
        return Item.objects.get(cuid=cuid)

Itemを作るMutationのテスト

cuidを追加した影響でItemを作れなくなる場合があるのでテストします。
ユニーク制約に違反しないことを確認するため、3回アイテムを作成できることを確認します。

import graphene
from graphene_django.utils.testing import GraphQLTestCase
from api.schema import Mutation, Query
from api.models import User


class TestItemMutation(GraphQLTestCase):
    def setUp(self):
        super().setUp()
        self.schema = graphene.Schema(query=Query, mutation=Mutation)
        self.me = User.objects.create(
            username="me",
            email="me@gmail.com",
            password="password",
        )

    # 3回連続でアイテムが作成できることを確認する
    def test_normal_1(self):
        for idx in range(3):
            # Act
            query = """
                mutation createItem {
                    createItem {
                        item {
                            id,
                        }
                    }
                }
            """

            response = self.schema.execute(
                query,
                context_value=graphene.Context(user=self.me),
            )

            # Assert
            expected = {
                "createItem": {
                    "item": {
                        "id": str(idx + 1),
                    }
                }
            }
            self.assertDictEqual(response.data, expected)

参考

https://www.denzow.me/entry/2017/12/23/150501

Discussion