├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.md ├── client └── java │ ├── com │ └── luojilab │ │ └── rock │ │ └── Crypto.java │ └── readme.md ├── mirage ├── __init__.py ├── crypto.py ├── exceptions.py ├── fields.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── mirage.py └── tools.py ├── requirements.txt ├── setup.py └── tests ├── __init__.py ├── apps ├── __init__.py ├── admin.py ├── apps.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_testmodel_textraw.py │ ├── 0003_testmodel_url.py │ ├── 0004_testmodel_json.py │ └── __init__.py ├── models.py ├── tests.py └── views.py ├── manage.py ├── mirage ├── settings ├── base.py ├── mysql.py └── pg.py ├── test_crypto.py ├── test_field.py └── urls.py /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | venv 3 | *.pyc 4 | dist 5 | django_mirage_field.egg-info/ 6 | build 7 | .vscode 8 | client/out 9 | client/client.iml 10 | client/java/META-INF 11 | __pycache__ 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Beijing logicreation Information & Technology Co., Ltd and individual contributors. 4 | All rights reserved. 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | prune client -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django-mirage-field 2 | 3 | ![](https://img.shields.io/pypi/v/django-mirage-field.svg?label=django-mirage-field) 4 | [![Hits](https://hits.seeyoufarm.com/api/count/incr/badge.svg?url=https%3A%2F%2Fgithub.com%2Fluojilab%2Fdjango-mirage-field&count_bg=%233DC8BC&title_bg=%23555555&icon=&icon_color=%23E7E7E7&title=views&edge_flat=false)](https://hits.seeyoufarm.com) 5 | 6 | ## NOTE!!! 7 | 8 | The maintainer [tcitry](https://github.com/tcitry) has resigned from luojilab, he won't contribute this project ever, the latest code please refer: [tcitry/django-mirage-field](https://github.com/tcitry/django-mirage-field). 9 | 10 | ## Introduce 11 | 12 | A Django model fields collection that encrypt your data when save to and decrypt when get from database. It keeps data always encrypted in database. Base on AES, it supports query method like `get()` and `filter()` in Django. 13 | 14 | Mirage can also migrate data from origin column to encrypted column in database with a good performance. 15 | 16 | ## Features 17 | 18 | * Use settings.SECRET_KEY as secret key default or anyelse which length >= 32 19 | * Support CharField, TextField, IntegerField, EmailField 20 | * Support Django ORM's `get()`, `filter()` query method 21 | * Support AES-256-ECB and AES-256-CBC(v1.2.0) 22 | * Support PostgreSQL and MySQL database 23 | * Support Django model field `db_index` and `unique` attributes 24 | 25 | ## Installation 26 | 27 | ```bash 28 | pip install django-mirage-field 29 | ``` 30 | 31 | ## Usage 32 | 33 | ```python 34 | from mirage import fields 35 | class TestModel(models.Model): 36 | age = fields.EncryptedIntegerField() 37 | ``` 38 | 39 | ```python 40 | obj = TestModel.objects.get(age=18) 41 | obj.id # 1 42 | obj.age # 18 43 | type(obj.age) # int 44 | ``` 45 | 46 | ```psql 47 | database=# select * from testmodel where id = 1; 48 | id | age 49 | ---------------------+-------------------------- 50 | 1 | -bYijegsEDrmS1s7ilnspA== 51 | ``` 52 | 53 | ```python 54 | from mirage.crypto import Crypto 55 | c = Crypto() # key is optional, default will use settings.SECRET_KEY 56 | c.encrypt('some_address') # -bYijegsEDrmS1s7ilnspA== 57 | c.decrypt('-bYijegsEDrmS1s7ilnspA==') # some_address 58 | ``` 59 | 60 | ## Settings 61 | 62 | - MIRAGE_SECRET_KEY 63 | - MIRAGE_CIPHER_MODE (v1.2.0+) 64 | - MIRAGE_CIPHER_IV (v1.2.0+) 65 | 66 | ### MIRAGE_SECRET_KEY 67 | 68 | You can use the `settings.SECRET_KEY` as default key, if you want custom another key for mirage, set the `MIRAGE_SECRET_KEY` in settings. 69 | 70 | Mirage will get the `settings.MIRAGE_SECRET_KEY` first, if not set, mirage will get the `settings.SECRET_KEY`. 71 | 72 | ### MIRAGE_CIPHER_MODE 73 | 74 | `MIRAGE_CIPHER_MODE` is optional, choices are below, If don't set, default is `ECB`. 75 | 76 | - `ECB` 77 | - `CBC` 78 | 79 | ### MIRAGE_CIPHER_IV 80 | 81 | `MIRAGE_CIPHER_IV` is optional, if you don't set, it will use a default: "1234567890abcdef", it's length must be 16. 82 | 83 | 84 | ## Model Fields 85 | 86 | 1. EncryptedTextField 87 | 2. EncryptedCharField 88 | 3. EncryptedEmailField 89 | 4. EncryptedIntegerField 90 | 5. EncryptedURLField(v1.3.0+) 91 | 92 | ## Data Migrate 93 | 94 | Add`mirage`to`INSTALLED_APPS` 95 | 96 | ### Way 1. Migrations 97 | 98 | add `app_name`,`model_name`,`field_name` in [migrations.RunPython](https://docs.djangoproject.com/en/2.2/ref/migration-operations/#runpython) 99 | 100 | ``` 101 | from mirage.tools import Migrator 102 | 103 | migrations.RunPython(Migrator("app_name", "model_name", "field_name").encrypt, reverse_code=Migrator("app_name", 'model_name', 'field_name').decrypt), 104 | ``` 105 | 106 | ### Way 2. Commands 107 | 108 | Options: 109 | 110 | * --app 111 | * --model 112 | * --field 113 | * --method (optional: `encrypt`, `decrypt`, `encrypt_to`, `decrypt_to`, `copy_to`) 114 | * --tofield (need when use `encryt_to`, `decrypt_to`, `copy_to` method) 115 | 116 | Optional options: 117 | 118 | * --offset ("select * from xxx where id > offset") 119 | * --total ("select * from xxx order by id limit total") 120 | * --limit: set the query count in every update, default is 1000, if you set -1, mirage will query all rows one time to update. 121 | 122 | Examples 123 | 124 | ``` 125 | ./manage.py mirage --app=yourapp --model=testmodel --field=address --method=encrypt --offset=2000000 --total=3000000 126 | 127 | ./manage.py mirage --app=yourapp --model=testmodel --field=address --method=encrypt_to --tofield=encrypted_address 128 | 129 | ``` 130 | 131 | ## Exceptions 132 | 133 | ``` 134 | from mirage import exceptions 135 | ``` 136 | 137 | 1. EncryptedFieldException 138 | 139 | ## Performance 140 | 141 | ### With ECB mode 142 | 143 | Migrate data: 6000,000 columns takes 40 minutes, Average 1 column/2.5ms 144 | 145 | Only encrypt/decrypt: Average 1 value/ms 146 | 147 | ## Clients 148 | 149 | * [Java](https://github.com/luojilab/django-mirage-field/tree/master/client/java) 150 | -------------------------------------------------------------------------------- /client/java/com/luojilab/rock/Crypto.java: -------------------------------------------------------------------------------- 1 | package com.luojilab.rock; 2 | 3 | import org.apache.commons.codec.binary.Base64; 4 | 5 | import javax.crypto.Cipher; 6 | import javax.crypto.spec.SecretKeySpec; 7 | 8 | 9 | public class Crypto { 10 | 11 | private static byte[] SECRET_KEY = "xxx_secret_key_32byte_xxx".getBytes(); 12 | 13 | public static String decrypt(String encrypted_text) { 14 | byte[] decrypted; 15 | try { 16 | Base64 base64 = new Base64(true); 17 | SecretKeySpec key = new SecretKeySpec(Crypto.SECRET_KEY, "AES"); 18 | Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding"); 19 | cipher.init(Cipher.DECRYPT_MODE, key); 20 | decrypted = cipher.doFinal(base64.decode(encrypted_text)); 21 | } catch (Exception e) { 22 | return encrypted_text; 23 | } 24 | return new String(decrypted); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /client/java/readme.md: -------------------------------------------------------------------------------- 1 | ## Example 2 | 3 | needs [Apache Commons Codec](https://mvnrepository.com/artifact/commons-codec/commons-codec)>=1.4 4 | 5 | ```java 6 | import com.luojilab.rock.Crypto; 7 | 8 | public class Main { 9 | 10 | public static void main(String[] args) { 11 | System.out.println(Crypto.decrypt("uqjmgU7SvG6-Isz-ThIwoA==")); 12 | } 13 | 14 | } 15 | ``` 16 | -------------------------------------------------------------------------------- /mirage/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luojilab/django-mirage-field/612f8f5008a0fb4ca139d5638810f136ed3d7e58/mirage/__init__.py -------------------------------------------------------------------------------- /mirage/crypto.py: -------------------------------------------------------------------------------- 1 | import base64 2 | 3 | from cryptography.hazmat.backends import default_backend 4 | from cryptography.hazmat.primitives import padding 5 | from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes 6 | 7 | from django.conf import settings 8 | from django.utils.encoding import force_bytes, force_str 9 | 10 | SHORT_SECRET_KEY: str = """ 11 | django-mirage-field key length (MIRAGE_SECRET_KEY or SECRET_KEY) must be longer than 32 characters! 12 | """ 13 | 14 | 15 | class BaseCipher: 16 | def __init__(self, key, iv) -> None: 17 | self.key = key 18 | self.iv = iv 19 | 20 | def encrypt(self, text) -> str: 21 | return text 22 | 23 | def decrypt(self, encrypted) -> str: 24 | return encrypted 25 | 26 | 27 | class ECBCipher(BaseCipher): 28 | 29 | def encrypt(self, text): 30 | encryptor = Cipher(algorithms.AES(self.key), 31 | modes.ECB(), default_backend()).encryptor() 32 | padder = padding.PKCS7(algorithms.AES(self.key).block_size).padder() 33 | padded_data = padder.update(force_bytes(text)) + padder.finalize() 34 | encrypted_text = encryptor.update(padded_data) + encryptor.finalize() 35 | return force_str(base64.urlsafe_b64encode(encrypted_text)) 36 | 37 | def decrypt(self, encrypted): 38 | decryptor = Cipher(algorithms.AES(self.key), 39 | modes.ECB(), default_backend()).decryptor() 40 | padder = padding.PKCS7(algorithms.AES(self.key).block_size).unpadder() 41 | decrypted_text = decryptor.update(base64.urlsafe_b64decode(encrypted)) 42 | unpadded_text = padder.update(decrypted_text) + padder.finalize() 43 | return force_str(unpadded_text) 44 | 45 | 46 | class CBCCipher(BaseCipher): 47 | 48 | def encrypt(self, text) -> str: 49 | encryptor = Cipher(algorithms.AES(self.key), modes.CBC( 50 | self.iv), default_backend()).encryptor() 51 | padder = padding.PKCS7(algorithms.AES(self.key).block_size).padder() 52 | padded_data = padder.update(force_bytes(text)) + padder.finalize() 53 | encrypted_text = encryptor.update(padded_data) + encryptor.finalize() 54 | return force_str(base64.urlsafe_b64encode(encrypted_text)) 55 | 56 | def decrypt(self, encrypted) -> str: 57 | decryptor = Cipher(algorithms.AES(self.key), modes.CBC( 58 | self.iv), default_backend()).decryptor() 59 | padder = padding.PKCS7(algorithms.AES(self.key).block_size).unpadder() 60 | decrypted_text = decryptor.update(base64.urlsafe_b64decode(encrypted)) 61 | unpadded_text = padder.update(decrypted_text) + padder.finalize() 62 | return force_str(unpadded_text) 63 | 64 | 65 | class Crypto: 66 | 67 | def __init__(self, key=None, mode=None, iv=None): 68 | if not key: 69 | key = getattr(settings, "MIRAGE_SECRET_KEY", 70 | None) or getattr(settings, "SECRET_KEY") 71 | assert len(key) >= 32, SHORT_SECRET_KEY 72 | key = base64.urlsafe_b64encode(force_bytes(key))[:32] 73 | if mode is None: 74 | mode = getattr(settings, "MIRAGE_CIPHER_MODE", "ECB") 75 | if iv is None: 76 | iv = getattr(settings, "MIRAGE_CIPHER_IV", "1234567890abcdef") 77 | self.cipher = eval(f"{mode}Cipher")(key=key, iv=force_bytes(iv)) 78 | 79 | def encrypt(self, text): 80 | if text is None: 81 | return None 82 | try: 83 | self.cipher.decrypt(text) 84 | return text 85 | except Exception: 86 | return self.cipher.encrypt(text) 87 | 88 | def decrypt(self, encrypted): 89 | if encrypted is None: 90 | return None 91 | try: 92 | return self.cipher.decrypt(encrypted) 93 | except Exception: 94 | return encrypted 95 | -------------------------------------------------------------------------------- /mirage/exceptions.py: -------------------------------------------------------------------------------- 1 | class EncryptedFieldException(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /mirage/fields.py: -------------------------------------------------------------------------------- 1 | import json 2 | from django.core import exceptions 3 | from django.db import models 4 | from .crypto import Crypto 5 | from .exceptions import EncryptedFieldException 6 | from django.db.models.fields.json import KeyTransform 7 | 8 | 9 | class EncryptedMixin(models.Field): 10 | internal_type = "CharField" 11 | prepared_max_length = None 12 | 13 | def __init__(self, key=None, **kwargs): 14 | kwargs.setdefault('max_length', self.prepared_max_length) 15 | self.crypto = Crypto(key) 16 | super().__init__(**kwargs) 17 | 18 | def get_db_prep_value(self, value, connection, prepared=False): 19 | value = super().get_db_prep_value(value, connection, prepared) 20 | if value is not None: 21 | encrypted_text = self.crypto.encrypt(value) 22 | if self.max_length and len(encrypted_text) > self.max_length: 23 | raise EncryptedFieldException( 24 | f"Field {self.name} max_length={self.max_length} encrypted_len={len(encrypted_text)}" 25 | ) 26 | return encrypted_text 27 | return None 28 | 29 | def from_db_value(self, value, expression, connection, *args): 30 | if value is None: 31 | return value 32 | return self.to_python(self.crypto.decrypt(value)) 33 | 34 | def get_internal_type(self): 35 | return self.internal_type 36 | 37 | 38 | class EncryptedTextField(EncryptedMixin, models.TextField): 39 | internal_type = "TextField" 40 | 41 | class EncryptedJSONField(EncryptedMixin, models.JSONField): 42 | internal_type = "TextField" 43 | 44 | def from_db_value(self, value, expression, connection): 45 | if value is None: 46 | return value 47 | if isinstance(expression, KeyTransform) and not isinstance(value, str): 48 | return value 49 | try: 50 | return json.loads(self.crypto.decrypt(value), cls=self.decoder) 51 | except json.JSONDecodeError as e: 52 | return self.crypto.decrypt(value) 53 | 54 | def get_prep_value(self, value): 55 | if value is None: 56 | return value 57 | return json.dumps(self.crypto.encrypt(json.dumps(value, cls=self.encoder)), cls=self.encoder) 58 | 59 | 60 | class EncryptedCharField(EncryptedMixin, models.CharField): 61 | prepared_max_length = 255 62 | 63 | 64 | class EncryptedURLField(EncryptedMixin, models.URLField): 65 | prepared_max_length = 200 66 | 67 | 68 | class EncryptedEmailField(EncryptedMixin, models.EmailField): 69 | prepared_max_length = 254 70 | 71 | 72 | class EncryptedIntegerField(EncryptedMixin, models.CharField): 73 | prepared_max_length = 64 74 | 75 | def to_python(self, value): 76 | try: 77 | return int(value) 78 | except (TypeError, ValueError): 79 | return value 80 | 81 | def check(self, **kwargs): 82 | return [ 83 | *super(models.CharField, self).check(**kwargs), 84 | ] 85 | -------------------------------------------------------------------------------- /mirage/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luojilab/django-mirage-field/612f8f5008a0fb4ca139d5638810f136ed3d7e58/mirage/management/__init__.py -------------------------------------------------------------------------------- /mirage/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luojilab/django-mirage-field/612f8f5008a0fb4ca139d5638810f136ed3d7e58/mirage/management/commands/__init__.py -------------------------------------------------------------------------------- /mirage/management/commands/mirage.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | 3 | from mirage.tools import Migrator 4 | 5 | 6 | class Command(BaseCommand): 7 | help = "encrypt/decrypt data use Mirage ~" 8 | 9 | def add_arguments(self, parser): 10 | parser.add_argument("--app", type=str, required=True) 11 | parser.add_argument("--model", type=str, required=True) 12 | parser.add_argument("--field", type=str, required=True) 13 | parser.add_argument("--method", type=str, required=True) 14 | parser.add_argument("--offset", type=int, required=False) 15 | parser.add_argument("--total", type=int, required=False) 16 | parser.add_argument("--limit", type=int, required=False) 17 | parser.add_argument("--tofield", type=str, required=False) 18 | parser.add_argument("--idfield", type=str, required=False) 19 | 20 | def handle(self, *args, **options): 21 | app = options["app"] 22 | model = options["model"] 23 | field = options["field"] 24 | method = options["method"] 25 | offset = options["offset"] or 0 26 | total = options["total"] 27 | limit = options["limit"] or 1000 28 | tofield = options["tofield"] 29 | idfield = options["idfield"] or "id" 30 | 31 | if method == "encrypt": 32 | Migrator(app, model, field, idfield=idfield).encrypt( 33 | offset=offset, total=total, limit=limit 34 | ) 35 | elif method == "decrypt": 36 | Migrator(app, model, field, idfield=idfield).decrypt( 37 | offset=offset, total=total, limit=limit 38 | ) 39 | elif method == "copy_to": 40 | assert tofield is not None 41 | Migrator(app, model, field, tofield=tofield, idfield=idfield).copy_to( 42 | offset=offset, total=total, limit=limit 43 | ) 44 | elif method == "decrypt_to": 45 | assert tofield is not None 46 | Migrator(app, model, field, tofield=tofield, idfield=idfield).decrypt_to( 47 | offset=offset, total=total, limit=limit 48 | ) 49 | elif method == "encrypt_to": 50 | assert tofield is not None 51 | Migrator(app, model, field, tofield=tofield, idfield=idfield).encrypt_to( 52 | offset=offset, total=total, limit=limit 53 | ) 54 | else: 55 | return 56 | -------------------------------------------------------------------------------- /mirage/tools.py: -------------------------------------------------------------------------------- 1 | from django.apps import apps as installed_apps 2 | from django.db import connections, router 3 | from tqdm import tqdm 4 | 5 | from .crypto import Crypto 6 | 7 | 8 | class Migrator: 9 | def __init__(self, app, model, field, key=None, tofield=None, idfield='id'): 10 | self.app = app 11 | self.model = model.lower() 12 | self.field = field.lower() 13 | self.crypto = Crypto(key) 14 | self.tofield = tofield 15 | self.idfield = idfield 16 | 17 | def encrypt(self, apps=None, schema_editor=None, offset=0, total=None, limit=1000): 18 | return self.executor(apps, schema_editor, offset, total, limit, method='encrypt') 19 | 20 | def decrypt(self, apps=None, schema_editor=None, offset=0, total=None, limit=1000): 21 | return self.executor(apps, schema_editor, offset, total, limit, method='decrypt') 22 | 23 | def copy_to(self, apps=None, schema_editor=None, offset=0, total=None, limit=1000): 24 | return self.executor(apps, schema_editor, offset, total, limit, method='copy_to') 25 | 26 | def encrypt_to(self, apps=None, schema_editor=None, offset=0, total=None, limit=1000): 27 | return self.executor(apps, schema_editor, offset, total, limit, method='encrypt_to') 28 | 29 | def decrypt_to(self, apps=None, schema_editor=None, offset=0, total=None, limit=1000): 30 | return self.executor(apps, schema_editor, offset, total, limit, method='decrypt_to') 31 | 32 | def executor(self, apps=None, schema_editor=None, offset=0, total=None, limit=1000, method=None): 33 | if not method: 34 | return 35 | 36 | if not apps: 37 | apps = installed_apps 38 | model = apps.get_model(self.app, self.model) 39 | if not schema_editor: 40 | db_alias = router.db_for_write(model=model) 41 | else: 42 | db_alias = schema_editor.connection.alias 43 | db_table = model._meta.db_table if model._meta.db_table else f"{self.app}_{self.model}" 44 | if limit == -1: 45 | total = model.objects.using(db_alias).count() 46 | else: 47 | if not total: 48 | last_record = model.objects.using( 49 | db_alias).order_by(f"-{self.idfield}").first() 50 | if last_record: 51 | total = getattr(last_record, self.idfield) 52 | else: 53 | total = 0 54 | if limit > total: 55 | limit = total 56 | 57 | t = tqdm(total=total-offset) 58 | while offset < total: 59 | value_list = [] 60 | with connections[db_alias].cursor() as cursor: 61 | if limit == -1: 62 | cursor.execute( 63 | f"select {self.idfield}, {self.field} from {db_table};") 64 | else: 65 | cursor.execute( 66 | f"select {self.idfield}, {self.field} from {db_table} where " 67 | f"{self.idfield}>{offset} order by {self.idfield} limit {limit};" 68 | ) 69 | for query in cursor.fetchall(): 70 | if method in ['encrypt', 'encrypt_to']: 71 | value_list.append( 72 | [query[0], self.crypto.encrypt(query[1])]) 73 | elif method in ['decrypt', 'decrypt_to']: 74 | text = self.crypto.decrypt(query[1]) 75 | if isinstance(text, str): 76 | text = text.replace("'", "''") 77 | value_list.append([query[0], text]) 78 | elif method == 'copy_to': 79 | text = query[1] 80 | if isinstance(text, str): 81 | text = text.replace("'", "''") 82 | value_list.append([query[0], text]) 83 | execute_sql = '' 84 | for value in value_list: 85 | if method in ['encrypt', 'decrypt']: 86 | if value[1] is None: 87 | execute_sql += ( 88 | f"update {db_table} set {self.field}=NULL where {self.idfield}='{value[0]}';" 89 | ) 90 | else: 91 | execute_sql += ( 92 | f"update {db_table} set {self.field}='{value[1]}' where {self.idfield}='{value[0]}';" 93 | ) 94 | elif method in ['copy_to', 'encrypt_to', 'decrypt_to']: 95 | if value[1] is None: 96 | execute_sql += ( 97 | f"update {db_table} set {self.tofield}=NULL where {self.idfield}='{value[0]}';" 98 | ) 99 | else: 100 | execute_sql += ( 101 | f"update {db_table} set {self.tofield}='{value[1]}' where {self.idfield}='{value[0]}';" 102 | ) 103 | cursor.execute(execute_sql) 104 | if value_list: 105 | if limit == -1: 106 | t.update(len(value_list) - offset) 107 | offset = total 108 | else: 109 | t.update(value_list[-1][0] - offset) 110 | offset = value_list[-1][0] 111 | t.close() 112 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | psycopg2 2 | pymysql 3 | cryptography 4 | tqdm 5 | django 6 | twine -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from setuptools import find_packages, setup 3 | 4 | with open(os.path.join(os.path.dirname(__file__), 'README.md'), encoding='utf8') as readme: 5 | README = readme.read() 6 | 7 | os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) 8 | setup( 9 | name='django-mirage-field', 10 | version='1.4.0', 11 | install_requires=[ 12 | "cryptography", 13 | "tqdm", 14 | ], 15 | packages=find_packages(exclude=["tests*"]), 16 | include_package_data=True, 17 | license='MIT License', 18 | description='A Django model fields collection that encrypt your data when save to and decrypt when get from database. It keeps data always encrypted in database.', 19 | long_description_content_type="text/markdown", 20 | long_description=README, 21 | url='https://github.com/luojilab/django-mirage-field', 22 | author='tcitry', 23 | author_email='tcitry@gmail.com', 24 | classifiers=[ 25 | 'Environment :: Web Environment', 26 | 'Framework :: Django', 27 | 'Framework :: Django :: 2.2', 28 | 'Framework :: Django :: 3.2', 29 | 'Framework :: Django :: 4.0', 30 | 'Intended Audience :: Developers', 31 | 'License :: OSI Approved :: MIT License', 32 | 'Operating System :: OS Independent', 33 | 'Programming Language :: Python', 34 | 'Programming Language :: Python :: 3.6', 35 | 'Programming Language :: Python :: 3.9', 36 | 'Topic :: Internet :: WWW/HTTP', 37 | ], 38 | ) 39 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | import pymysql 2 | pymysql.install_as_MySQLdb() 3 | -------------------------------------------------------------------------------- /tests/apps/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luojilab/django-mirage-field/612f8f5008a0fb4ca139d5638810f136ed3d7e58/tests/apps/__init__.py -------------------------------------------------------------------------------- /tests/apps/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.contrib.admin.models import LogEntry 3 | from .models import TestModel 4 | 5 | 6 | @admin.register(LogEntry) 7 | class DjangoLogAdmin(admin.ModelAdmin): 8 | list_display = ['id', 'content_type', 'user', 9 | 'object_repr', 'change_message', 'action_time'] 10 | search_fields = ['user_id'] 11 | 12 | 13 | @admin.register(TestModel) 14 | class DjangoLogAdmin(admin.ModelAdmin): 15 | list_display = ['id', 'char', 'text', 'textraw', 'integer', 'email'] 16 | search_fields = ['id', 'char', 'text', 'textraw', 'integer', 'email'] 17 | -------------------------------------------------------------------------------- /tests/apps/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class AppsConfig(AppConfig): 5 | name = 'apps' 6 | -------------------------------------------------------------------------------- /tests/apps/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.5 on 2021-11-23 08:43 2 | 3 | from django.db import migrations, models 4 | import mirage.fields 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | initial = True 10 | 11 | dependencies = [ 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='TestModel', 17 | fields=[ 18 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 19 | ('char', mirage.fields.EncryptedCharField(blank=True, max_length=255, null=True)), 20 | ('text', mirage.fields.EncryptedTextField(blank=True, null=True)), 21 | ('integer', mirage.fields.EncryptedIntegerField(blank=True, max_length=64, null=True)), 22 | ('email', mirage.fields.EncryptedEmailField(blank=True, max_length=254, null=True)), 23 | ], 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /tests/apps/migrations/0002_testmodel_textraw.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.5 on 2021-11-23 09:07 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('apps', '0001_initial'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='testmodel', 15 | name='textraw', 16 | field=models.TextField(blank=True, null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /tests/apps/migrations/0003_testmodel_url.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0 on 2021-12-25 03:30 2 | 3 | from django.db import migrations 4 | import mirage.fields 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('apps', '0002_testmodel_textraw'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='testmodel', 16 | name='url', 17 | field=mirage.fields.EncryptedURLField(blank=True, null=True), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /tests/apps/migrations/0004_testmodel_json.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0 on 2022-03-14 02:41 2 | 3 | from django.db import migrations 4 | import mirage.fields 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('apps', '0003_testmodel_url'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='testmodel', 16 | name='json', 17 | field=mirage.fields.EncryptedJSONField(default={}), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /tests/apps/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luojilab/django-mirage-field/612f8f5008a0fb4ca139d5638810f136ed3d7e58/tests/apps/migrations/__init__.py -------------------------------------------------------------------------------- /tests/apps/models.py: -------------------------------------------------------------------------------- 1 | from email.policy import default 2 | from django.db import models 3 | from mirage import fields 4 | 5 | 6 | class TestModel(models.Model): 7 | char = fields.EncryptedCharField(blank=True, null=True) 8 | text = fields.EncryptedTextField(blank=True, null=True) 9 | textraw = models.TextField(blank=True, null=True) 10 | integer = fields.EncryptedIntegerField(blank=True, null=True) 11 | email = fields.EncryptedEmailField(blank=True, null=True) 12 | url = fields.EncryptedURLField(blank=True, null=True) 13 | json = fields.EncryptedJSONField(default={}) 14 | -------------------------------------------------------------------------------- /tests/apps/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /tests/apps/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | # Create your views here. 4 | -------------------------------------------------------------------------------- /tests/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == '__main__': 6 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'settings.pg') 7 | try: 8 | from django.core.management import execute_from_command_line 9 | except ImportError as exc: 10 | raise ImportError( 11 | "Couldn't import Django. Are you sure it's installed and " 12 | "available on your PYTHONPATH environment variable? Did you " 13 | "forget to activate a virtual environment?" 14 | ) from exc 15 | execute_from_command_line(sys.argv) 16 | -------------------------------------------------------------------------------- /tests/mirage: -------------------------------------------------------------------------------- 1 | ../mirage -------------------------------------------------------------------------------- /tests/settings/base.py: -------------------------------------------------------------------------------- 1 | # base64.urlsafe_encode('django-mirage-field have fun') 2 | SECRET_KEY = "ZGphbmdvLW1pcmFnZS1maWVsZCBoYXZlIGZ1bg==" 3 | 4 | DEBUG = True 5 | STATIC_URL = "/static/" 6 | ROOT_URLCONF = "urls" 7 | 8 | MIRAGE_SECRET_KEY = "MIRAGE_SECRET_KEYMIRAGE_SECRET_KEY" 9 | MIRAGE_CIPHER_MODE = "CBC" 10 | MIRAGE_CIPHER_IV = "1234567890abcdef" 11 | 12 | INSTALLED_APPS = [ 13 | 'django.contrib.admin', 14 | 'django.contrib.auth', 15 | 'django.contrib.contenttypes', 16 | 'django.contrib.sessions', 17 | 'django.contrib.messages', 18 | 'django.contrib.staticfiles', 19 | 'apps', 20 | 'mirage', 21 | ] 22 | 23 | MIDDLEWARE = [ 24 | 'django.middleware.security.SecurityMiddleware', 25 | 'django.contrib.sessions.middleware.SessionMiddleware', 26 | 'django.middleware.common.CommonMiddleware', 27 | 'django.middleware.csrf.CsrfViewMiddleware', 28 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 29 | 'django.contrib.messages.middleware.MessageMiddleware', 30 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 31 | ] 32 | 33 | TEMPLATES = [ 34 | { 35 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 36 | 'DIRS': [], 37 | 'APP_DIRS': True, 38 | 'OPTIONS': { 39 | 'context_processors': [ 40 | 'django.template.context_processors.debug', 41 | 'django.template.context_processors.request', 42 | 'django.contrib.auth.context_processors.auth', 43 | 'django.contrib.messages.context_processors.messages', 44 | ], 45 | }, 46 | }, 47 | ] 48 | -------------------------------------------------------------------------------- /tests/settings/mysql.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | 3 | 4 | DATABASES = { 5 | 'default': { 6 | 'ENGINE': 'django.db.backends.mysql', 7 | "NAME": 'mirage', 8 | 'USER': 'root', 9 | 'PASSWORD': '12345678', 10 | 'HOST': '127.0.0.1', 11 | 'PORT': '3306', 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /tests/settings/pg.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | 3 | 4 | DATABASES = { 5 | 'default': { 6 | 'ENGINE': 'django.db.backends.postgresql_psycopg2', 7 | "NAME": 'mirage', 8 | 'USER': 'yindongliang', 9 | 'PASSWORD': 'yindongliang', 10 | 'HOST': '127.0.0.1', 11 | 'PORT': '5432', 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /tests/test_crypto.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from mirage.crypto import Crypto 3 | 4 | 5 | class TestCryptoECB(TestCase): 6 | 7 | def setUp(self): 8 | self.crypto = Crypto(mode='ECB') 9 | self.value = 'hello,text' 10 | self.encrypted = "4DIIbNsZPqO1DuXX1GjpkQ==" 11 | 12 | def test_ecb_encrypt(self): 13 | self.assertEqual(self.crypto.encrypt(self.value), self.encrypted) 14 | 15 | def test_ecb_decrypt(self): 16 | self.assertEqual(self.crypto.decrypt(self.encrypted), self.value) 17 | 18 | 19 | class TestCryptoCBC(TestCase): 20 | 21 | def setUp(self) -> None: 22 | self.crypto = Crypto(mode='CBC') 23 | self.value = 'hello,text' 24 | self.encrypted = "E_RFOSafjW-FQ-PDkXkv5g==" 25 | 26 | def test_cbc_encrypt(self): 27 | self.assertEqual(self.crypto.encrypt(self.value), self.encrypted) 28 | 29 | def test_cbc_decrypt(self): 30 | self.assertEqual(self.crypto.decrypt(self.encrypted), self.value) 31 | -------------------------------------------------------------------------------- /tests/test_field.py: -------------------------------------------------------------------------------- 1 | import json 2 | from django.test import TestCase 3 | from apps.models import TestModel 4 | 5 | 6 | class TestField(TestCase): 7 | CHAR = 'hello,char' 8 | TEXT = 'hello,text' 9 | INTEGER = 1234567890 10 | EMAIL = 'hello@email.com' 11 | URL = 'https://yindongliang.com' 12 | JSON = {"hello": "world", "foo": "bar"} 13 | 14 | @classmethod 15 | def setUpTestData(cls): 16 | obj = TestModel.objects.create() 17 | obj.char = cls.CHAR 18 | obj.text = cls.TEXT 19 | obj.integer = cls.INTEGER 20 | obj.email = cls.EMAIL 21 | obj.url = cls.URL 22 | obj.json = cls.JSON 23 | obj.save() 24 | 25 | def setUp(self): 26 | self.obj = TestModel.objects.latest('id') 27 | 28 | def test_char_field(self): 29 | self.assertEqual(self.obj.char, self.CHAR) 30 | self.assertEqual(type(self.obj.char), str) 31 | 32 | def test_text_field(self): 33 | self.assertEqual(self.obj.text, self.TEXT) 34 | self.assertEqual(type(self.obj.text), str) 35 | 36 | def test_int_field(self): 37 | self.assertEqual(self.obj.integer, self.INTEGER) 38 | self.assertEqual(type(self.obj.integer), int) 39 | 40 | def test_email_field(self): 41 | self.assertEqual(self.obj.email, self.EMAIL) 42 | self.assertEqual(type(self.obj.email), str) 43 | 44 | def test_url_field(self): 45 | self.assertEqual(self.obj.url, self.URL) 46 | self.assertEqual(type(self.obj.url), str) 47 | 48 | def test_json_field(self): 49 | self.assertEqual(self.obj.json, self.JSON) 50 | self.assertEqual(type(self.obj.json), dict) 51 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | """mirage_demo URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/3.0/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.contrib import admin 17 | from django.urls import path 18 | 19 | urlpatterns = [ 20 | path('admin/', admin.site.urls), 21 | ] 22 | --------------------------------------------------------------------------------