在 Django 模型中安全存储秘密密钥(Fernet 版本)

安全是开发者的责任 – 不能把原文直接存入数据库。

1. 为什么需要加密?



  • API Key / Secret / OAuth 令牌 等值,一旦泄露,整个服务就相当于被破解
  • 如果硬编码在 settings.py, → Git 仓库、部署产物、开发者笔记本等地方都会直接暴露
  • 即使放在环境变量中, → 再把该值直接存入数据库,数据库被攻破时就会以明文泄露

另一个必须注意的点:

  • 密码(password)
  • 服务器不需要知道原文 → 使用哈希(BCrypt、Argon2)
  • API Key / Secret / 令牌
  • 服务器需要再次使用原文 → 需要可解密的加密

总结

  • 需要再次使用的敏感数据加密后存储
  • 不需要再次使用的数据(密码等)哈希后存储

2. Fernet 是什么?

cryptography.fernet.Fernet 是一个一次性提供高层 API。

  • 内部使用安全算法(AES + HMAC 等)
  • 随机 IV时间戳完整性校验(HMAC) 等自动处理
  • 开发者只需妥善保管密钥,然后
  • encrypt(明文) → 令牌字符串
  • decrypt(令牌) → 明文 只需调用一次即可

换句话说,省去了直接处理 AES-CBC/IV/填充/HMAC 的麻烦:

from cryptography.fernet import Fernet

key = Fernet.generate_key()      # 生成密钥
f = Fernet(key)

token = f.encrypt(b"hello")      # 加密
plain = f.decrypt(token)         # 解密

只要理解到这点,就足以使用。

我们需要关注的只有两件事

  1. 密钥管理(环境变量、Secret Manager 等)
  2. 在 Django 中写入/读取时自动加密/解密

3. Fernet 密钥生成与 Django 设置



3-1. 生成 Fernet 密钥

# 生成一个 Fernet 密钥(Base64 字符串)
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"

示例输出:

twwhe-3rGfW92V5rvvW3o4Jhr0rVg7x8lQM6zCj6lTY=

将此字符串放入服务器环境变量。

export DJANGO_FERNET_KEY="twwhe-3rGfW92V5rvvW3o4Jhr0rVg7x8lQM6zCj6lTY="

3-2. 在 settings.py 中准备 Fernet 实例

# settings.py
import os
from cryptography.fernet import Fernet

DJANGO_FERNET_KEY = os.environ["DJANGO_FERNET_KEY"]  # 字符串
FERNET = Fernet(DJANGO_FERNET_KEY.encode("utf-8"))

现在在任何地方都可以这样使用:

from django.conf import settings
settings.FERNET.encrypt(...)
settings.FERNET.decrypt(...)

4. 在 Django 模型中添加 Fernet 加密字段

不需要实现复杂的块加密,只需一个极简的自定义字段即可。

4-1. 设计要点

  • 数据库中只存加密后的令牌字符串
  • 模型/视图/服务代码始终只看到解密后的明文
  • 采用 _secret + secret 的模式,将数据库列与业务意义分离

4-2. EncryptedTextField 的实现

# myapp/utils/encrypted_field.py
from django.db import models
from django.conf import settings


class EncryptedTextField(models.TextField):
    """
    基于 Fernet 的加密 TextField
    - 存储时:明文(str) → Fernet 令牌(str)
    - 读取时:Fernet 令牌(str) → 明文(str)
    """

    description = "Fernet encrypted text field"

    def get_prep_value(self, value):
        """
        Python 对象 → 数据库存储值
        """
        if value is None:
            return None

        if isinstance(value, str):
            if value == "":
                return ""

            token = settings.FERNET.encrypt(value.encode("utf-8"))
            return token.decode("utf-8")

        return value

    def from_db_value(self, value, expression, connection):
        """
        数据库值 → Python 对象
        """
        if value is None:
            return None

        try:
            token = value.encode("utf-8")
            decrypted = settings.FERNET.decrypt(token)
            return decrypted.decode("utf-8")
        except Exception:
            return value

    def to_python(self, value):
        """
        ORM 处理时的 Python 对象
        """
        return value

实务提示

  • 如果最初数据库里已有明文数据, 在 from_db_value 中让解密失败时返回原值, 之后逐步读取 → 再写入时就会变成加密值。

5. 在模型中使用

# myapp/models.py
from django.db import models
from .utils.encrypted_field import EncryptedTextField


class MyModel(models.Model):
    name = models.CharField(max_length=100)
    _secret = EncryptedTextField()   # 实际数据库列(Fernet 令牌)

    @property
    def secret(self):
        """
        对外使用时始终返回明文
        """
        return self._secret

    @secret.setter
    def secret(self, value: str):
        """
        赋值时自动加密
        """
        self._secret = value
  • _secret
  • 真实数据库列名
  • 存储的是 Fernet 令牌(加密字符串)
  • secret
  • 代码中使用的“逻辑字段”
  • 始终只处理明文

这样,后续查看模型时可以清晰区分:

obj.secret  # 明文
obj._secret # 加密值

6. 使用示例

6-1. Django shell

>>> from myapp.models import MyModel

# 新建对象
>>> obj = MyModel(name="Test")
>>> obj.secret = "my-very-secret-key"   # 赋值明文
>>> obj.save()

# 数据库中实际存储的是加密令牌
>>> MyModel.objects.get(id=obj.id)._secret
'gAAAAABm...4xO8Uu1f0L3K0Ty6fX5y6Zt3_k6D7eE-'  # 示例

# 模型使用时得到明文
>>> obj.secret
'my-very-secret-key'

6-2. API 中使用

通常不会把 secret 直接返回给客户端,而是仅在后端内部使用。示例:

# myapp/views.py
from rest_framework.views import APIView
from rest_framework.response import Response
from .models import MyModel


class SecretView(APIView):
    def get(self, request, pk):
        obj = MyModel.objects.get(pk=pk)
        # obj.secret 已经是明文(已解密)
        return Response({"secret": obj.secret})

在实际项目中,常见做法是:

  • obj.secret 进行外部 API 调用
  • 将其直接暴露给前端

7. 使用 Fernet 时需要注意的事项

7-1. 搜索/过滤限制

Fernet 的加密结果每次都会不同

obj1.secret = "abc"
obj2.secret = "abc"

虽然明文相同,但 _secret 存储的令牌完全不同。

因此,以下查询无效:

MyModel.objects.filter(_secret="abc")       # 无意义
MyModel.objects.filter(_secret=obj._secret) # 只能按令牌比较,无法按明文搜索

如果需要按明文搜索/排序,建议:

  • 另外维护搜索列(哈希、前缀等)
  • 或者重新评估是否需要加密该字段

7-2. 密钥轮换策略

不建议一直使用同一密钥,最好定期更换。

  • 简单做法:
  • 只用 DJANGO_FERNET_KEY
  • 进阶做法:
  • 维护 OLD_KEYNEW_KEY
  • 加密时仅用 NEW_KEY
  • 解密时尝试 OLD_KEYNEW_KEY
  • 随着数据逐步迁移到 NEW_KEY,最终移除 OLD_KEY

此部分可在单独文章中详细展开。


8. 安全检查清单(Fernet 版本)

项目 检查点
密钥管理 DJANGO_FERNET_KEY 不要放到 Git,使用 AWS Secrets Manager / Parameter Store / Vault 等
权限分离 仅在生产服务器注入密钥,开发/本地使用不同密钥
加密目标 只加密真正需要的值(API Key、Secret、令牌等),其余保持普通列
日志 切勿在日志中输出明文/令牌/密钥(尤其是 DEBUG 日志)
备份 备份数据时,若无密钥则数据无意义
测试 单元测试验证 encrypt -> decrypt 一致,迁移(原始明文)处理正确

9. 结语

  • 低级 AES 实现难懂且易出错
  • 采用 Fernet 可以:
  • 组合安全算法
  • 自动处理随机 IV、时间戳、完整性校验
  • 提供简洁 API

在 Django 中:

  • 使用 EncryptedTextField + _secret / secret 模式,
  • 开发者只需处理明文,数据库始终存储加密值。

结论

  • 敏感数据 = 必须加密后存储
  • 复杂加密算法交给成熟库 只要遵守这两点,Django 服务的安全水平就能大幅提升。 🔐

image