Skip to content

Commit e91b8eb

Browse files
Turn prefix and hashed_key into proper APIKey fields (#62)
* Turn prefix and hashed_key into proper APIKey fields * Add v1.4 upgrade guide * Update changelog, remove v from upgrade guide filename
1 parent 290548c commit e91b8eb

File tree

13 files changed

+211
-24
lines changed

13 files changed

+211
-24
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
**NOTE**: this release contains migrations. See [Upgrade to v1.4](https://florimondmanca.github.io/djangorestframework-api-key/upgrade/1.4/) for detailed instructions.
11+
12+
### Added
13+
14+
- The `prefix` and `hashed_key` are now stored in dedicated fields on the `APIKey` model.
15+
1016
## [v1.3.0] - 2019-06-28
1117

1218
**NOTE**: this release contains migrations. In your Django project, run them using:

docs/upgrade/1.4.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Upgrading to 1.4
2+
3+
## Introduction
4+
5+
The 1.4 release includes a migration that adds and populates the `prefix` and `hashed_key` fields to API keys.
6+
7+
This document lists the steps necessary to upgrade from 1.3.x to 1.4.
8+
9+
## Steps
10+
11+
### 1. Migrate the built-in API key model
12+
13+
The `APIKey` model can be migrated using the migration shipped with this package:
14+
15+
```bash
16+
python manage.py migrate rest_framework_api_key
17+
```
18+
19+
### 2. Migrate custom API key models (if applicable)
20+
21+
If you have a custom API key model deriving from `AbstractAPIKey`, you need to **manually add the migration** to your application.
22+
23+
- Copy the migration script below to your app's `migrations/` directory. Be sure to modify `APP_NAME`, `MODEL_NAME` and `DEPENDENCIES` as seems fit. You can name the migration script `xxxx_prefix_hashed_key.py` (replace `xxxx` with the next available migration ID).
24+
25+
```python
26+
--8<-- "rest_framework_api_key/migrations/0004_prefix_hashed_key.py"
27+
```
28+
29+
- Apply the migration:
30+
31+
```bash
32+
python manage.py migrate <my_app>
33+
```

mkdocs.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ nav:
1414
- Home: index.md
1515
- User Guide: guide.md
1616
- Security: security.md
17+
- Upgrade Guides:
18+
- "1.4": upgrade/1.4.md
1719
- Contributing: https://github.com/florimondmanca/djangorestframework-api-key/tree/master/CONTRIBUTING.md
1820
- Changelog: https://github.com/florimondmanca/djangorestframework-api-key/tree/master/CHANGELOG.md
1921
- License: https://github.com/florimondmanca/djangorestframework-api-key/tree/master/LICENSE
@@ -22,6 +24,8 @@ markdown_extensions:
2224
- admonition
2325
- footnotes
2426
- pymdownx.superfences
27+
- pymdownx.snippets:
28+
check_paths: true
2529
- codehilite:
2630
guess_lang: false
2731
- toc:

rest_framework_api_key/admin.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77

88
class APIKeyModelAdmin(admin.ModelAdmin):
99
list_display = (
10-
"name",
1110
"prefix",
11+
"name",
1212
"created",
1313
"expiry_date",
1414
"_has_expired",

rest_framework_api_key/crypto.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@
77
from .models import APIKey
88

99

10+
def concatenate(left: str, right: str) -> str:
11+
return "{}.{}".format(left, right)
12+
13+
14+
def split(concatenated: str) -> Tuple[str, str]:
15+
left, _, right = concatenated.partition(".")
16+
return left, right
17+
18+
1019
class KeyGenerator:
1120
def __init__(self, prefix_length: int = 8, secret_key_length: int = 32):
1221
self.prefix_length = prefix_length
@@ -18,16 +27,15 @@ def get_prefix(self) -> str:
1827
def get_secret_key(self) -> str:
1928
return get_random_string(self.secret_key_length)
2029

21-
@staticmethod
22-
def concatenate(left: str, right: str) -> str:
23-
return "{}.{}".format(left, right)
30+
def hash(self, value: str) -> str:
31+
return make_password(value)
2432

25-
def generate(self) -> Tuple[str, str]:
33+
def generate(self) -> Tuple[str, str, str]:
2634
prefix = self.get_prefix()
2735
secret_key = self.get_secret_key()
28-
key = self.concatenate(prefix, secret_key)
29-
hashed_key = self.concatenate(prefix, make_password(key))
30-
return key, hashed_key
36+
key = concatenate(prefix, secret_key)
37+
hashed_key = self.hash(key)
38+
return key, prefix, hashed_key
3139

3240
def verify(self, key: str, hashed_key: str) -> bool:
3341
return check_password(key, hashed_key)
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Generated by Django 2.2.2 on 2019-06-29 10:38
2+
3+
from django.db import migrations, models
4+
5+
APP_NAME = "rest_framework_api_key"
6+
MODEL_NAME = "apikey"
7+
DEPENDENCIES = [(APP_NAME, "0003_auto_20190623_1952")]
8+
9+
10+
def populate_prefix_hashed_key(apps, schema_editor):
11+
model = apps.get_model(APP_NAME, MODEL_NAME)
12+
13+
for api_key in model.objects.all():
14+
prefix, _, hashed_key = api_key.id.partition(".")
15+
api_key.prefix = prefix
16+
api_key.hashed_key = hashed_key
17+
api_key.save()
18+
19+
20+
class Migration(migrations.Migration):
21+
22+
dependencies = DEPENDENCIES
23+
24+
operations = [
25+
migrations.AddField(
26+
model_name=MODEL_NAME,
27+
name="hashed_key",
28+
field=models.CharField(max_length=100, null=True),
29+
),
30+
migrations.AddField(
31+
model_name=MODEL_NAME,
32+
name="prefix",
33+
field=models.CharField(max_length=8, unique=True, null=True),
34+
),
35+
migrations.RunPython(
36+
populate_prefix_hashed_key, migrations.RunPython.noop
37+
),
38+
migrations.AlterField(
39+
model_name=MODEL_NAME,
40+
name="hashed_key",
41+
field=models.CharField(max_length=100, editable=False),
42+
),
43+
migrations.AlterField(
44+
model_name=MODEL_NAME,
45+
name="prefix",
46+
field=models.CharField(max_length=8, unique=True, editable=False),
47+
),
48+
]

rest_framework_api_key/models.py

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,26 @@
44
from django.db import models
55
from django.utils import timezone
66

7-
from .crypto import KeyGenerator
7+
from .crypto import concatenate, KeyGenerator, split
88

99

1010
class BaseAPIKeyManager(models.Manager):
1111
key_generator = KeyGenerator()
1212

1313
def assign_key(self, obj: "AbstractAPIKey") -> str:
14-
key, hashed_key = self.key_generator.generate()
15-
obj.id = hashed_key
14+
try:
15+
key, prefix, hashed_key = self.key_generator.generate()
16+
except TypeError: # Compatibility with < 1.4
17+
key, hashed_key = self.key_generator.generate()
18+
pk = hashed_key
19+
prefix, hashed_key = split(hashed_key)
20+
else:
21+
pk = concatenate(prefix, hashed_key)
22+
23+
obj.id = pk
24+
obj.prefix = prefix
25+
obj.hashed_key = hashed_key
26+
1627
return key
1728

1829
def create_key(self, **kwargs) -> Tuple["AbstractAPIKey", str]:
@@ -32,10 +43,8 @@ def is_valid(self, key: str) -> bool:
3243
queryset = self.get_usable_keys()
3344

3445
try:
35-
api_key = queryset.get(
36-
id__startswith=prefix
37-
) # type: AbstractAPIKey
38-
except (self.model.DoesNotExist, self.model.MultipleObjectsReturned):
46+
api_key = queryset.get(prefix=prefix) # type: AbstractAPIKey
47+
except self.model.DoesNotExist:
3948
return False
4049

4150
if not api_key.is_valid(key):
@@ -57,6 +66,8 @@ class AbstractAPIKey(models.Model):
5766
id = models.CharField(
5867
max_length=100, unique=True, primary_key=True, editable=False
5968
)
69+
prefix = models.CharField(max_length=8, unique=True, editable=False)
70+
hashed_key = models.CharField(max_length=100, editable=False)
6071
created = models.DateTimeField(auto_now_add=True, db_index=True)
6172
name = models.CharField(
6273
max_length=50,
@@ -94,12 +105,6 @@ def __init__(self, *args, **kwargs):
94105
# Store the initial value of `revoked` to detect changes.
95106
self._initial_revoked = self.revoked
96107

97-
def _prefix(self) -> str:
98-
return self.pk.partition(".")[0]
99-
100-
_prefix.short_description = "Prefix"
101-
prefix = property(_prefix)
102-
103108
def _has_expired(self) -> bool:
104109
if self.expiry_date is None:
105110
return False
@@ -110,8 +115,7 @@ def _has_expired(self) -> bool:
110115
has_expired = property(_has_expired)
111116

112117
def is_valid(self, key: str) -> bool:
113-
_, _, hashed_key = self.pk.partition(".")
114-
return type(self).objects.key_generator.verify(key, hashed_key)
118+
return type(self).objects.key_generator.verify(key, self.hashed_key)
115119

116120
def clean(self):
117121
self._validate_revoked()

tests/legacy/__init__.py

Whitespace-only changes.

tests/legacy/crypto.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from typing import Tuple
2+
from rest_framework_api_key.crypto import concatenate, KeyGenerator
3+
4+
5+
class LegacyKeyGenerator(KeyGenerator):
6+
"""Pre-1.4 key generator."""
7+
8+
def generate(self) -> Tuple[str, str, str]:
9+
prefix = self.get_prefix()
10+
secret_key = self.get_secret_key()
11+
key = concatenate(prefix, secret_key)
12+
hashed_key = concatenate(prefix, self.hash(key))
13+
return key, hashed_key

tests/legacy/permissions.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from rest_framework_api_key.permissions import HasAPIKey
2+
from .crypto import LegacyKeyGenerator
3+
4+
5+
class HasAPIKeyWithLegacyKeyGenerator(HasAPIKey):
6+
key_generator = LegacyKeyGenerator()

0 commit comments

Comments
 (0)