django-esi 8.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- django_esi-8.1.0.dist-info/METADATA +93 -0
- django_esi-8.1.0.dist-info/RECORD +100 -0
- django_esi-8.1.0.dist-info/WHEEL +4 -0
- django_esi-8.1.0.dist-info/licenses/LICENSE +674 -0
- esi/__init__.py +7 -0
- esi/admin.py +42 -0
- esi/aiopenapi3/client.py +79 -0
- esi/aiopenapi3/plugins.py +224 -0
- esi/app_settings.py +112 -0
- esi/apps.py +11 -0
- esi/checks.py +56 -0
- esi/clients.py +657 -0
- esi/decorators.py +271 -0
- esi/errors.py +22 -0
- esi/exceptions.py +51 -0
- esi/helpers.py +63 -0
- esi/locale/cs_CZ/LC_MESSAGES/django.mo +0 -0
- esi/locale/cs_CZ/LC_MESSAGES/django.po +53 -0
- esi/locale/de/LC_MESSAGES/django.mo +0 -0
- esi/locale/de/LC_MESSAGES/django.po +58 -0
- esi/locale/en/LC_MESSAGES/django.mo +0 -0
- esi/locale/en/LC_MESSAGES/django.po +54 -0
- esi/locale/es/LC_MESSAGES/django.mo +0 -0
- esi/locale/es/LC_MESSAGES/django.po +59 -0
- esi/locale/fr_FR/LC_MESSAGES/django.mo +0 -0
- esi/locale/fr_FR/LC_MESSAGES/django.po +59 -0
- esi/locale/it_IT/LC_MESSAGES/django.mo +0 -0
- esi/locale/it_IT/LC_MESSAGES/django.po +59 -0
- esi/locale/ja/LC_MESSAGES/django.mo +0 -0
- esi/locale/ja/LC_MESSAGES/django.po +58 -0
- esi/locale/ko_KR/LC_MESSAGES/django.mo +0 -0
- esi/locale/ko_KR/LC_MESSAGES/django.po +58 -0
- esi/locale/nl_NL/LC_MESSAGES/django.mo +0 -0
- esi/locale/nl_NL/LC_MESSAGES/django.po +53 -0
- esi/locale/pl_PL/LC_MESSAGES/django.mo +0 -0
- esi/locale/pl_PL/LC_MESSAGES/django.po +53 -0
- esi/locale/ru/LC_MESSAGES/django.mo +0 -0
- esi/locale/ru/LC_MESSAGES/django.po +61 -0
- esi/locale/sk/LC_MESSAGES/django.mo +0 -0
- esi/locale/sk/LC_MESSAGES/django.po +55 -0
- esi/locale/uk/LC_MESSAGES/django.mo +0 -0
- esi/locale/uk/LC_MESSAGES/django.po +57 -0
- esi/locale/zh_Hans/LC_MESSAGES/django.mo +0 -0
- esi/locale/zh_Hans/LC_MESSAGES/django.po +58 -0
- esi/management/commands/__init__.py +0 -0
- esi/management/commands/esi_clear_spec_cache.py +21 -0
- esi/management/commands/generate_esi_stubs.py +661 -0
- esi/management/commands/migrate_to_ssov2.py +188 -0
- esi/managers.py +303 -0
- esi/managers.pyi +85 -0
- esi/migrations/0001_initial.py +55 -0
- esi/migrations/0002_scopes_20161208.py +56 -0
- esi/migrations/0003_hide_tokens_from_admin_site.py +23 -0
- esi/migrations/0004_remove_unique_access_token.py +18 -0
- esi/migrations/0005_remove_token_length_limit.py +23 -0
- esi/migrations/0006_remove_url_length_limit.py +18 -0
- esi/migrations/0007_fix_mysql_8_migration.py +18 -0
- esi/migrations/0008_nullable_refresh_token.py +18 -0
- esi/migrations/0009_set_old_tokens_to_sso_v1.py +18 -0
- esi/migrations/0010_set_new_tokens_to_sso_v2.py +18 -0
- esi/migrations/0011_add_token_indices.py +28 -0
- esi/migrations/0012_fix_token_type_choices.py +18 -0
- esi/migrations/0013_squashed_0012_fix_token_type_choices.py +57 -0
- esi/migrations/__init__.py +0 -0
- esi/models.py +349 -0
- esi/openapi_clients.py +1225 -0
- esi/rate_limiting.py +107 -0
- esi/signals.py +21 -0
- esi/static/esi/img/EVE_SSO_Login_Buttons_Large_Black.png +0 -0
- esi/static/esi/img/EVE_SSO_Login_Buttons_Large_White.png +0 -0
- esi/static/esi/img/EVE_SSO_Login_Buttons_Small_Black.png +0 -0
- esi/static/esi/img/EVE_SSO_Login_Buttons_Small_White.png +0 -0
- esi/stubs.py +2 -0
- esi/stubs.pyi +6807 -0
- esi/tasks.py +78 -0
- esi/templates/esi/select_token.html +116 -0
- esi/templatetags/__init__.py +0 -0
- esi/templatetags/scope_tags.py +8 -0
- esi/tests/__init__.py +134 -0
- esi/tests/client_authed_pilot.py +63 -0
- esi/tests/client_public_pilot.py +53 -0
- esi/tests/factories.py +47 -0
- esi/tests/factories_2.py +60 -0
- esi/tests/jwt_factory.py +135 -0
- esi/tests/test_checks.py +48 -0
- esi/tests/test_clients.py +1019 -0
- esi/tests/test_decorators.py +578 -0
- esi/tests/test_management_command.py +307 -0
- esi/tests/test_managers.py +673 -0
- esi/tests/test_models.py +403 -0
- esi/tests/test_openapi.json +854 -0
- esi/tests/test_openapi.py +1017 -0
- esi/tests/test_swagger.json +489 -0
- esi/tests/test_swagger_full.json +51112 -0
- esi/tests/test_tasks.py +116 -0
- esi/tests/test_templatetags.py +22 -0
- esi/tests/test_views.py +331 -0
- esi/tests/threading_pilot.py +69 -0
- esi/urls.py +9 -0
- esi/views.py +129 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from django.core.management.base import BaseCommand, CommandError
|
|
3
|
+
|
|
4
|
+
from django.db.migrations.recorder import MigrationRecorder
|
|
5
|
+
from django.utils import timezone
|
|
6
|
+
|
|
7
|
+
from esi.models import Token
|
|
8
|
+
from esi.errors import (
|
|
9
|
+
TokenInvalidError,
|
|
10
|
+
IncompleteResponseError,
|
|
11
|
+
NotRefreshableTokenError,
|
|
12
|
+
)
|
|
13
|
+
from esi import app_settings
|
|
14
|
+
from oauthlib.oauth2.rfc6749.errors import (
|
|
15
|
+
InvalidClientIdError,
|
|
16
|
+
InvalidGrantError,
|
|
17
|
+
InvalidTokenError,
|
|
18
|
+
)
|
|
19
|
+
from requests.auth import HTTPBasicAuth
|
|
20
|
+
|
|
21
|
+
from tqdm import tqdm
|
|
22
|
+
|
|
23
|
+
from requests_oauthlib import OAuth2Session
|
|
24
|
+
|
|
25
|
+
import pytz
|
|
26
|
+
|
|
27
|
+
EVE_SSOV1_END_DATE = pytz.UTC.localize(
|
|
28
|
+
datetime(year=2021, month=11, day=1, hour=0, minute=0)
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _sso_v1_refresh(
|
|
33
|
+
session: OAuth2Session, auth: HTTPBasicAuth, token: Token, message: str
|
|
34
|
+
):
|
|
35
|
+
"""
|
|
36
|
+
SSOv1 Refresh.
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
_data = session.refresh_token(
|
|
40
|
+
"https://login.eveonline.com/oauth/token",
|
|
41
|
+
refresh_token=token.refresh_token,
|
|
42
|
+
auth=auth,
|
|
43
|
+
)
|
|
44
|
+
token.access_token = _data["access_token"]
|
|
45
|
+
token.refresh_token = _data["refresh_token"]
|
|
46
|
+
token.sso_version = 1
|
|
47
|
+
token.created = timezone.now()
|
|
48
|
+
token.save()
|
|
49
|
+
return (True, None)
|
|
50
|
+
except (InvalidGrantError):
|
|
51
|
+
return (
|
|
52
|
+
False,
|
|
53
|
+
(
|
|
54
|
+
"ID:%s '%s' %s SSOv1 Refresh failed"
|
|
55
|
+
" (InvalidGrant)" % (token.id, token.character_name, message)
|
|
56
|
+
),
|
|
57
|
+
)
|
|
58
|
+
except (InvalidTokenError, InvalidClientIdError):
|
|
59
|
+
return (
|
|
60
|
+
False,
|
|
61
|
+
(
|
|
62
|
+
"ID:%s '%s' %s SSOv1 Refresh Failed "
|
|
63
|
+
"(InvalidToken, InvalidClientId)"
|
|
64
|
+
% (token.id, token.character_name, message)
|
|
65
|
+
),
|
|
66
|
+
)
|
|
67
|
+
except Exception as e:
|
|
68
|
+
return (
|
|
69
|
+
False,
|
|
70
|
+
(
|
|
71
|
+
"ID:%s '%s' %s SSOv1 "
|
|
72
|
+
"Refresh Failed (%s)" % (token.id, token.character_name, message, e)
|
|
73
|
+
),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Command(BaseCommand):
|
|
78
|
+
help = "Attempt to Migrate all SSOv1 Tokens to SSOv2, and report failures."
|
|
79
|
+
requires_migrations_checks = True
|
|
80
|
+
requires_system_checks = '__all__'
|
|
81
|
+
|
|
82
|
+
def add_arguments(self, parser):
|
|
83
|
+
parser.add_argument(
|
|
84
|
+
"--skip-v1-checks",
|
|
85
|
+
action="store_true",
|
|
86
|
+
help="Do not test on SSOv1 Endpoints, both before"
|
|
87
|
+
" updating to SSOv2 and after a failure.",
|
|
88
|
+
)
|
|
89
|
+
parser.add_argument(
|
|
90
|
+
"--purge",
|
|
91
|
+
action="store_true",
|
|
92
|
+
help="Purge Tokens that fail the SSOv2 Update",
|
|
93
|
+
)
|
|
94
|
+
parser.add_argument("-n", help="Number of Tokens to update", type=int)
|
|
95
|
+
|
|
96
|
+
def handle(self, *args, **options):
|
|
97
|
+
if timezone.now() > EVE_SSOV1_END_DATE:
|
|
98
|
+
self.stdout.write(
|
|
99
|
+
self.style.WARNING(
|
|
100
|
+
"WARNING: Conversion of SSO v1 tokens is not garenteered to work "
|
|
101
|
+
f"past {EVE_SSOV1_END_DATE.strftime('%x')}."
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
use_v1 = not options.get("skip_v1_checks", False)
|
|
105
|
+
purge = options.get("purge", False)
|
|
106
|
+
batch = options.get("n", False)
|
|
107
|
+
|
|
108
|
+
migration_10 = MigrationRecorder.Migration.objects.filter(
|
|
109
|
+
app="esi", name="0010_set_new_tokens_to_sso_v2"
|
|
110
|
+
).exists()
|
|
111
|
+
|
|
112
|
+
if not migration_10:
|
|
113
|
+
raise CommandError("Run migrations first and try again!")
|
|
114
|
+
else:
|
|
115
|
+
self.stdout.write("\n\nMigrations up to date. Proceeding to updates!")
|
|
116
|
+
|
|
117
|
+
if not use_v1:
|
|
118
|
+
self.stdout.write("\n\nSkipping SSOv1 pre/post error Verification!")
|
|
119
|
+
|
|
120
|
+
# lets reuse our own session and auth
|
|
121
|
+
session = OAuth2Session(app_settings.ESI_SSO_CLIENT_ID)
|
|
122
|
+
auth = HTTPBasicAuth(
|
|
123
|
+
app_settings.ESI_SSO_CLIENT_ID, app_settings.ESI_SSO_CLIENT_SECRET
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# only do tokens that are SSOv1
|
|
127
|
+
tokens = Token.objects.filter(sso_version=1)
|
|
128
|
+
if batch:
|
|
129
|
+
tokens = tokens[:batch]
|
|
130
|
+
total = tokens.count()
|
|
131
|
+
|
|
132
|
+
if total > 0:
|
|
133
|
+
self.stdout.write("There are %s Tokens to update." % (total))
|
|
134
|
+
else:
|
|
135
|
+
return self.stdout.write("There are no Tokens to update.")
|
|
136
|
+
|
|
137
|
+
failures = []
|
|
138
|
+
# Nice Progress bar as this may take a while.
|
|
139
|
+
with tqdm(total=total) as t:
|
|
140
|
+
for token in tokens:
|
|
141
|
+
if use_v1:
|
|
142
|
+
result, message = _sso_v1_refresh(session, auth, token, "Initial")
|
|
143
|
+
if not result:
|
|
144
|
+
failures.append(message)
|
|
145
|
+
if purge:
|
|
146
|
+
token.delete()
|
|
147
|
+
t.update(1)
|
|
148
|
+
continue
|
|
149
|
+
try:
|
|
150
|
+
token.refresh(session=session, auth=auth)
|
|
151
|
+
except (TokenInvalidError, IncompleteResponseError):
|
|
152
|
+
if use_v1:
|
|
153
|
+
result, messaage = _sso_v1_refresh(
|
|
154
|
+
session, auth, token, "Post v2 Failure"
|
|
155
|
+
)
|
|
156
|
+
if not result:
|
|
157
|
+
failures.append(messaage)
|
|
158
|
+
if purge:
|
|
159
|
+
token.delete()
|
|
160
|
+
else:
|
|
161
|
+
failures.append(
|
|
162
|
+
"ID:%s '%s' SSOv2 Migration failed"
|
|
163
|
+
" (SSOv1 Re-Verification Ok)"
|
|
164
|
+
% (token.id, token.character_name)
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
else:
|
|
168
|
+
failures.append(
|
|
169
|
+
"ID:%s '%s' SSOv2 Migration failed"
|
|
170
|
+
" (TokenInvalidError, "
|
|
171
|
+
"IncompleteResponseError)"
|
|
172
|
+
% (token.id, token.character_name)
|
|
173
|
+
)
|
|
174
|
+
if purge:
|
|
175
|
+
token.delete()
|
|
176
|
+
except NotRefreshableTokenError:
|
|
177
|
+
failures.append(
|
|
178
|
+
"ID:%s '%s' SSOv2 Migration failed"
|
|
179
|
+
" (NotRefreshableTokenError)" % (token.id, token.character_name)
|
|
180
|
+
)
|
|
181
|
+
if purge:
|
|
182
|
+
token.delete()
|
|
183
|
+
|
|
184
|
+
t.update(1)
|
|
185
|
+
|
|
186
|
+
self.stdout.write("Completed Updates!")
|
|
187
|
+
self.stdout.write("%s Failures!" % (len(failures)))
|
|
188
|
+
self.stdout.write("\n".join(failures))
|
esi/managers.py
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from datetime import timedelta
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
from jose import jwt
|
|
7
|
+
from jose.exceptions import ExpiredSignatureError, JWTError
|
|
8
|
+
from requests_oauthlib import OAuth2Session
|
|
9
|
+
|
|
10
|
+
from django.db import models
|
|
11
|
+
from django.utils import timezone
|
|
12
|
+
|
|
13
|
+
from . import app_settings
|
|
14
|
+
from .errors import IncompleteResponseError, TokenError
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _process_scopes(scopes) -> set[str]:
|
|
20
|
+
if scopes is None:
|
|
21
|
+
# support filtering by no scopes with None passed
|
|
22
|
+
scopes = []
|
|
23
|
+
if not isinstance(scopes, models.QuerySet) and len(scopes) == 1:
|
|
24
|
+
# support a single space-delimited string inside a list because :users:
|
|
25
|
+
scopes = scopes[0]
|
|
26
|
+
# support space-delimited string scopes or lists
|
|
27
|
+
if isinstance(scopes, str):
|
|
28
|
+
scopes = set(scopes.split())
|
|
29
|
+
return {str(s) for s in scopes}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TokenQueryset(models.QuerySet["Token"]):
|
|
33
|
+
def get_expired(self) -> "TokenQueryset":
|
|
34
|
+
"""Get all tokens which have expired.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
All expired tokens.
|
|
38
|
+
"""
|
|
39
|
+
max_age = \
|
|
40
|
+
timezone.now() - timedelta(seconds=app_settings.ESI_TOKEN_VALID_DURATION)
|
|
41
|
+
return self.filter(created__lte=max_age)
|
|
42
|
+
|
|
43
|
+
def bulk_refresh(self) -> "TokenQueryset":
|
|
44
|
+
"""Refresh all refreshable tokens in the queryset and delete any expired token
|
|
45
|
+
that fails to refresh or can not be refreshed.
|
|
46
|
+
|
|
47
|
+
Excludes tokens for which the refresh was incomplete for other reasons.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
All refreshed tokens
|
|
51
|
+
"""
|
|
52
|
+
session = OAuth2Session(app_settings.ESI_SSO_CLIENT_ID)
|
|
53
|
+
auth = requests.auth.HTTPBasicAuth(
|
|
54
|
+
app_settings.ESI_SSO_CLIENT_ID, app_settings.ESI_SSO_CLIENT_SECRET
|
|
55
|
+
)
|
|
56
|
+
incomplete = []
|
|
57
|
+
for model in self.filter(refresh_token__isnull=False):
|
|
58
|
+
try:
|
|
59
|
+
model.refresh(session=session, auth=auth)
|
|
60
|
+
logging.debug("Successfully refreshed %r", model)
|
|
61
|
+
except TokenError:
|
|
62
|
+
logger.info("Refresh failed for %r. Deleting.", model)
|
|
63
|
+
model.delete()
|
|
64
|
+
except IncompleteResponseError:
|
|
65
|
+
incomplete.append(model.pk)
|
|
66
|
+
self.filter(refresh_token__isnull=True).get_expired().delete()
|
|
67
|
+
return self.exclude(pk__in=incomplete)
|
|
68
|
+
|
|
69
|
+
def require_valid(self) -> "TokenQueryset":
|
|
70
|
+
"""Ensure all tokens are still valid and attempt to refresh any which are expired
|
|
71
|
+
|
|
72
|
+
Deletes those which fail to refresh or cannot be refreshed.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
All tokens which are still valid.
|
|
76
|
+
"""
|
|
77
|
+
expired_pks = set(self.get_expired().values_list("pk", flat=True))
|
|
78
|
+
fresh_pks = set(self.exclude(pk__in=expired_pks).values_list("pk", flat=True))
|
|
79
|
+
refreshed = self.filter(pk__in=expired_pks).bulk_refresh()
|
|
80
|
+
refreshed_pks = set(refreshed.values_list("pk", flat=True))
|
|
81
|
+
qs = self.filter(pk__in=fresh_pks | refreshed_pks)
|
|
82
|
+
return qs
|
|
83
|
+
|
|
84
|
+
def require_scopes(self, scope_string: str | list) -> "TokenQueryset":
|
|
85
|
+
"""Filter tokens which have at least a subset of given scopes.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
scope_string: The required scopes.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
Tokens which have all requested scopes.
|
|
92
|
+
"""
|
|
93
|
+
scopes = _process_scopes(scope_string)
|
|
94
|
+
if not scopes:
|
|
95
|
+
# asking for tokens with no scopes
|
|
96
|
+
return self.filter(scopes__isnull=True)
|
|
97
|
+
from .models import Scope
|
|
98
|
+
scope_pks = Scope.objects.filter(name__in=scopes).values_list('pk', flat=True)
|
|
99
|
+
if not len(scopes) == len(scope_pks):
|
|
100
|
+
# there's a scope we don't recognize, so we can't have any tokens for it
|
|
101
|
+
return self.none()
|
|
102
|
+
tokens = self.all()
|
|
103
|
+
for pk in scope_pks:
|
|
104
|
+
tokens = tokens.filter(scopes__pk=pk)
|
|
105
|
+
return tokens
|
|
106
|
+
|
|
107
|
+
def require_scopes_exact(self, scope_string: str | list) -> "TokenQueryset":
|
|
108
|
+
"""Filter tokens which exactly have the given scopes.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
scope_string: The required scopes.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
Tokens which have all requested scopes.
|
|
115
|
+
"""
|
|
116
|
+
num_scopes = len(_process_scopes(scope_string))
|
|
117
|
+
scopes_qs = self\
|
|
118
|
+
.annotate(models.Count('scopes'))\
|
|
119
|
+
.require_scopes(scope_string)\
|
|
120
|
+
.filter(scopes__count=num_scopes)\
|
|
121
|
+
.values('pk', 'scopes__id')
|
|
122
|
+
pks = [v['pk'] for v in scopes_qs]
|
|
123
|
+
return self.filter(pk__in=pks)
|
|
124
|
+
|
|
125
|
+
def equivalent_to(self, token) -> "TokenQueryset":
|
|
126
|
+
"""Fetch all tokens which match the character and scopes of given reference token
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
token: :class:`esi.models.Token` reference token
|
|
130
|
+
"""
|
|
131
|
+
return self\
|
|
132
|
+
.filter(character_id=token.character_id)\
|
|
133
|
+
.require_scopes_exact(token.scopes.all())\
|
|
134
|
+
.filter(models.Q(user=token.user) | models.Q(user__isnull=True))\
|
|
135
|
+
.exclude(pk=token.pk)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class TokenManager(models.Manager["Token"]):
|
|
139
|
+
def get_queryset(self) -> TokenQueryset:
|
|
140
|
+
"""
|
|
141
|
+
Replace base queryset model with custom TokenQueryset
|
|
142
|
+
:rtype: :class:`esi.managers.TokenQueryset`
|
|
143
|
+
"""
|
|
144
|
+
return TokenQueryset(self.model, using=self._db)
|
|
145
|
+
|
|
146
|
+
@staticmethod
|
|
147
|
+
def _decode_jwt(jwt_token: str, jwk_set: dict, issuer: Any) -> dict[str, Any]:
|
|
148
|
+
"""
|
|
149
|
+
Helper function to decide the JWT access token supplied by EVE SSO
|
|
150
|
+
"""
|
|
151
|
+
logger.debug("Start Decode")
|
|
152
|
+
token_data = jwt.decode(
|
|
153
|
+
jwt_token,
|
|
154
|
+
jwk_set,
|
|
155
|
+
algorithms=jwk_set["alg"],
|
|
156
|
+
audience=app_settings.ESI_TOKEN_JWT_AUDIENCE,
|
|
157
|
+
issuer=issuer
|
|
158
|
+
)
|
|
159
|
+
token_detail = token_data.get("sub", None).split(":")
|
|
160
|
+
token_data['character_id'] = int(token_detail[2])
|
|
161
|
+
token_data['token_type'] = token_detail[0].lower()
|
|
162
|
+
logger.debug(token_data)
|
|
163
|
+
return token_data
|
|
164
|
+
|
|
165
|
+
@staticmethod
|
|
166
|
+
def validate_access_token(token: str) -> dict[str, Any] | None:
|
|
167
|
+
"""
|
|
168
|
+
Validate a JWT token retrieved from the EVE SSO.
|
|
169
|
+
:param token: A JWT token originating from the EVE SSO v2
|
|
170
|
+
:return: :class:`dict` The contents of the validated JWT token if
|
|
171
|
+
there are no validation errors
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
res = requests.get(app_settings.ESI_TOKEN_JWK_SET_URL)
|
|
175
|
+
res.raise_for_status()
|
|
176
|
+
data = res.json()
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
jwk_sets = data["keys"]
|
|
180
|
+
except KeyError as e:
|
|
181
|
+
logger.warning(
|
|
182
|
+
"Something went wrong when retrieving the JWK set. "
|
|
183
|
+
"The returned payload did not have the expected key %s.\n"
|
|
184
|
+
"Payload returned from the SSO looks like: %s",
|
|
185
|
+
e,
|
|
186
|
+
data
|
|
187
|
+
)
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
jwk_set = [item for item in jwk_sets if item["alg"] == "RS256"].pop()
|
|
191
|
+
try:
|
|
192
|
+
return TokenManager._decode_jwt(
|
|
193
|
+
token,
|
|
194
|
+
jwk_set,
|
|
195
|
+
("login.eveonline.com", "https://login.eveonline.com")
|
|
196
|
+
)
|
|
197
|
+
except ExpiredSignatureError:
|
|
198
|
+
logger.warning("The JWT token has expired")
|
|
199
|
+
return None
|
|
200
|
+
except JWTError as e:
|
|
201
|
+
logger.warning("The JWT signature was invalid: %s", e)
|
|
202
|
+
return None
|
|
203
|
+
|
|
204
|
+
def create_from_code(self, code, user=None) -> "Token":
|
|
205
|
+
"""
|
|
206
|
+
Perform OAuth code exchange to retrieve a token.
|
|
207
|
+
:param code: OAuth grant code.
|
|
208
|
+
:param user: User who will own token.
|
|
209
|
+
:return: :class:`esi.models.Token`
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
# perform code exchange
|
|
213
|
+
logger.debug("Creating new token from code %s", code[:-5])
|
|
214
|
+
oauth = OAuth2Session(
|
|
215
|
+
app_settings.ESI_SSO_CLIENT_ID,
|
|
216
|
+
redirect_uri=app_settings.ESI_SSO_CALLBACK_URL
|
|
217
|
+
)
|
|
218
|
+
token = oauth.fetch_token(
|
|
219
|
+
app_settings.ESI_TOKEN_URL,
|
|
220
|
+
client_secret=app_settings.ESI_SSO_CLIENT_SECRET,
|
|
221
|
+
code=code
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
token_data = TokenManager.validate_access_token(token.get('access_token', None))
|
|
225
|
+
|
|
226
|
+
# translate returned data to a model
|
|
227
|
+
model = self.create(
|
|
228
|
+
character_id=token_data['character_id'],
|
|
229
|
+
character_name=token_data['name'],
|
|
230
|
+
character_owner_hash=token_data['owner'],
|
|
231
|
+
access_token=token['access_token'],
|
|
232
|
+
refresh_token=token['refresh_token'],
|
|
233
|
+
token_type=token_data['token_type'],
|
|
234
|
+
user=user,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
# parse scopes
|
|
238
|
+
if 'scp' in token_data:
|
|
239
|
+
from esi.models import Scope
|
|
240
|
+
|
|
241
|
+
# if a single scope is supplied its a string... recast to list
|
|
242
|
+
if isinstance(token_data['scp'], str):
|
|
243
|
+
token_data['scp'] = [token_data['scp']]
|
|
244
|
+
|
|
245
|
+
for s in token_data['scp']:
|
|
246
|
+
try:
|
|
247
|
+
scope = Scope.objects.get(name=s)
|
|
248
|
+
model.scopes.add(scope)
|
|
249
|
+
except Scope.DoesNotExist:
|
|
250
|
+
# This scope isn't included in a data migration.
|
|
251
|
+
# Create a placeholder until it updates.
|
|
252
|
+
try:
|
|
253
|
+
help_text = s.split('.')[1].replace('_', ' ').capitalize()
|
|
254
|
+
except IndexError:
|
|
255
|
+
# Unusual scope name, missing periods.
|
|
256
|
+
help_text = s.replace('_', ' ').capitalize()
|
|
257
|
+
scope = Scope.objects.create(name=s, help_text=help_text)
|
|
258
|
+
model.scopes.add(scope)
|
|
259
|
+
logger.debug("Added %d scopes to new token.", model.scopes.all().count())
|
|
260
|
+
|
|
261
|
+
if not app_settings.ESI_ALWAYS_CREATE_TOKEN:
|
|
262
|
+
# see if we already have a token for this character and scope combination
|
|
263
|
+
# if so, we don't need a new one
|
|
264
|
+
queryset = self.get_queryset().equivalent_to(model)
|
|
265
|
+
if queryset.exists():
|
|
266
|
+
logger.debug(
|
|
267
|
+
"Identified %d tokens equivalent to new token. "
|
|
268
|
+
"Updating access and refresh tokens.",
|
|
269
|
+
queryset.count()
|
|
270
|
+
)
|
|
271
|
+
queryset.update(
|
|
272
|
+
access_token=model.access_token,
|
|
273
|
+
refresh_token=model.refresh_token,
|
|
274
|
+
created=model.created,
|
|
275
|
+
)
|
|
276
|
+
if queryset.filter(user=model.user).exists():
|
|
277
|
+
logger.debug(
|
|
278
|
+
"Equivalent token with same user exists. Deleting new token."
|
|
279
|
+
)
|
|
280
|
+
model.delete()
|
|
281
|
+
model = queryset.filter(user=model.user)[0] # pick one at random
|
|
282
|
+
|
|
283
|
+
logger.debug("Successfully created %r for user %s", model, user)
|
|
284
|
+
return model
|
|
285
|
+
|
|
286
|
+
def create_from_request(self, request) -> "Token":
|
|
287
|
+
"""
|
|
288
|
+
Generate a token from the OAuth callback request. Must contain 'code' in GET.
|
|
289
|
+
:param request: OAuth callback request.
|
|
290
|
+
:return: :class:`esi.models.Token`
|
|
291
|
+
"""
|
|
292
|
+
logger.debug(
|
|
293
|
+
"Creating new token for %s session %s",
|
|
294
|
+
request.user,
|
|
295
|
+
request.session.session_key[:5]
|
|
296
|
+
)
|
|
297
|
+
code = request.GET.get('code')
|
|
298
|
+
# attach a user during creation for some functionality in a post_save created
|
|
299
|
+
# receiver I'm working on elsewhere
|
|
300
|
+
model = self.create_from_code(
|
|
301
|
+
code, user=request.user if request.user.is_authenticated else None
|
|
302
|
+
)
|
|
303
|
+
return model
|
esi/managers.pyi
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
from django.db.models import QuerySet, Manager
|
|
3
|
+
from .models import Token
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TokenQueryset(QuerySet[Token]):
|
|
7
|
+
def get_expired(self) -> "TokenQueryset":
|
|
8
|
+
...
|
|
9
|
+
|
|
10
|
+
def bulk_refresh(self) -> "TokenQueryset":
|
|
11
|
+
...
|
|
12
|
+
|
|
13
|
+
def require_scopes(self, scope_string: str | list) -> "TokenQueryset":
|
|
14
|
+
...
|
|
15
|
+
|
|
16
|
+
def require_scopes_exact(self, scope_string: str | list) -> "TokenQueryset":
|
|
17
|
+
...
|
|
18
|
+
|
|
19
|
+
def require_valid(self) -> "TokenQueryset":
|
|
20
|
+
...
|
|
21
|
+
|
|
22
|
+
def equivalent_to(self, token: Token) -> "TokenQueryset":
|
|
23
|
+
...
|
|
24
|
+
|
|
25
|
+
def filter(self, *args: Any, **kwargs: Any) -> "TokenQueryset":
|
|
26
|
+
...
|
|
27
|
+
|
|
28
|
+
def exclude(self, *args: Any, **kwargs: Any) -> "TokenQueryset":
|
|
29
|
+
...
|
|
30
|
+
|
|
31
|
+
def order_by(self, *field_names: str) -> "TokenQueryset":
|
|
32
|
+
...
|
|
33
|
+
|
|
34
|
+
def annotate(self, *args: Any, **kwargs: Any) -> "TokenQueryset":
|
|
35
|
+
...
|
|
36
|
+
|
|
37
|
+
def select_related(self, *fields: str) -> "TokenQueryset":
|
|
38
|
+
...
|
|
39
|
+
|
|
40
|
+
def prefetch_related(self, *lookups: str) -> "TokenQueryset":
|
|
41
|
+
...
|
|
42
|
+
|
|
43
|
+
def distinct(self, *field_names: str) -> "TokenQueryset":
|
|
44
|
+
...
|
|
45
|
+
|
|
46
|
+
def none(self) -> "TokenQueryset":
|
|
47
|
+
...
|
|
48
|
+
|
|
49
|
+
def all(self) -> "TokenQueryset":
|
|
50
|
+
...
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class TokenManager(Manager[Token]):
|
|
54
|
+
def get_queryset(self) -> "TokenQueryset":
|
|
55
|
+
...
|
|
56
|
+
|
|
57
|
+
def require_scopes(self, scope_string: str | list) -> "TokenQueryset":
|
|
58
|
+
...
|
|
59
|
+
|
|
60
|
+
def filter(self, *args: Any, **kwargs: Any) -> "TokenQueryset":
|
|
61
|
+
...
|
|
62
|
+
|
|
63
|
+
def exclude(self, *args: Any, **kwargs: Any) -> "TokenQueryset":
|
|
64
|
+
...
|
|
65
|
+
|
|
66
|
+
def order_by(self, *field_names: str) -> "TokenQueryset":
|
|
67
|
+
...
|
|
68
|
+
|
|
69
|
+
def annotate(self, *args: Any, **kwargs: Any) -> "TokenQueryset":
|
|
70
|
+
...
|
|
71
|
+
|
|
72
|
+
def select_related(self, *fields: str) -> "TokenQueryset":
|
|
73
|
+
...
|
|
74
|
+
|
|
75
|
+
def prefetch_related(self, *lookups: str) -> "TokenQueryset":
|
|
76
|
+
...
|
|
77
|
+
|
|
78
|
+
def distinct(self, *field_names: str) -> "TokenQueryset":
|
|
79
|
+
...
|
|
80
|
+
|
|
81
|
+
def none(self) -> "TokenQueryset":
|
|
82
|
+
...
|
|
83
|
+
|
|
84
|
+
def all(self) -> "TokenQueryset":
|
|
85
|
+
...
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Generated by Django 1.10.1 on 2016-11-10 23:27
|
|
2
|
+
|
|
3
|
+
from django.conf import settings
|
|
4
|
+
from django.db import migrations, models
|
|
5
|
+
import django.db.models.deletion
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Migration(migrations.Migration):
|
|
9
|
+
|
|
10
|
+
initial = True
|
|
11
|
+
|
|
12
|
+
dependencies = [
|
|
13
|
+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
operations = [
|
|
17
|
+
migrations.CreateModel(
|
|
18
|
+
name='CallbackRedirect',
|
|
19
|
+
fields=[
|
|
20
|
+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
21
|
+
('url', models.CharField(default='/', help_text='The internal URL to redirect this callback towards.', max_length=254)),
|
|
22
|
+
('session_key', models.CharField(help_text='Session key identifying the session this redirect was created for.', max_length=254, unique=True)),
|
|
23
|
+
('state', models.CharField(help_text='OAuth2 state string representing this session.', max_length=128)),
|
|
24
|
+
('created', models.DateTimeField(auto_now_add=True)),
|
|
25
|
+
],
|
|
26
|
+
),
|
|
27
|
+
migrations.CreateModel(
|
|
28
|
+
name='Scope',
|
|
29
|
+
fields=[
|
|
30
|
+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
31
|
+
('name', models.CharField(help_text='The official EVE name for the scope.', max_length=100, unique=True)),
|
|
32
|
+
('help_text', models.TextField(help_text='The official EVE description of the scope.')),
|
|
33
|
+
],
|
|
34
|
+
),
|
|
35
|
+
migrations.CreateModel(
|
|
36
|
+
name='Token',
|
|
37
|
+
fields=[
|
|
38
|
+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
39
|
+
('created', models.DateTimeField(auto_now_add=True)),
|
|
40
|
+
('access_token', models.CharField(help_text='The access token granted by SSO.', max_length=254, unique=True)),
|
|
41
|
+
('refresh_token', models.CharField(blank=True, help_text='A re-usable token to generate new access tokens upon expiry.', max_length=254, null=True)),
|
|
42
|
+
('character_id', models.IntegerField(help_text='The ID of the EVE character who authenticated by SSO.')),
|
|
43
|
+
('character_name', models.CharField(help_text='The name of the EVE character who authenticated by SSO.', max_length=100)),
|
|
44
|
+
('token_type', models.CharField(choices=[('Character', 'Character'), ('Corporation', 'Corporation')], default='Character', help_text='The applicable range of the token.', max_length=100)),
|
|
45
|
+
('character_owner_hash', models.CharField(help_text='The unique string identifying this character and its owning EVE account. Changes if the owning account changes.', max_length=254)),
|
|
46
|
+
('scopes', models.ManyToManyField(blank=True, help_text='The access scopes granted by this token.', to='esi.Scope')),
|
|
47
|
+
('user', models.ForeignKey(blank=True, help_text='The user to whom this token belongs.', null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
|
48
|
+
],
|
|
49
|
+
),
|
|
50
|
+
migrations.AddField(
|
|
51
|
+
model_name='callbackredirect',
|
|
52
|
+
name='token',
|
|
53
|
+
field=models.ForeignKey(blank=True, help_text='Token generated by a completed code exchange from callback processing.', null=True, on_delete=django.db.models.deletion.CASCADE, to='esi.Token'),
|
|
54
|
+
),
|
|
55
|
+
]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Generated by Django 1.10.3 on 2016-11-10 15:12
|
|
2
|
+
|
|
3
|
+
from django.db import migrations
|
|
4
|
+
|
|
5
|
+
SCOPES = {
|
|
6
|
+
'esi-planets.manage_planets.v1':
|
|
7
|
+
"Allows reading a list of a character's planetary colonies, and the details of those colonies.",
|
|
8
|
+
'esi-ui.open_window.v1': "Allows open window in game client remotely.",
|
|
9
|
+
'esi-assets.read_assets.v1': "Allows reading a list of assets that the character owns.",
|
|
10
|
+
'esi-calendar.read_calendar_events.v1': "Allows reading a character's calendar, including corporate events.",
|
|
11
|
+
'esi-bookmarks.read_character_bookmarks.v1': "Allows reading of a character's bookmarks and bookmark folders.",
|
|
12
|
+
'esi-wallet.read_character_wallet.v1': "Allows reading of a character's wallet, journal and transaction history.",
|
|
13
|
+
'esi-clones.read_clones.v1': "Allows reading the locations of a character's jump clones and their implants.",
|
|
14
|
+
'esi-characters.read_contacts.v1':
|
|
15
|
+
"Allows reading of a character's contacts list, and calculation of CSPA charges.",
|
|
16
|
+
'esi-corporations.read_corporation_membership.v1':
|
|
17
|
+
"Allows reading a list of the ID's and roles of a character's fellow corporation members.",
|
|
18
|
+
'esi-fleets.read_fleet.v1': "Allows reading information about fleets.",
|
|
19
|
+
'esi-killmails.read_killmails.v1': "Allows reading of a character's kills and losses.",
|
|
20
|
+
'esi-location.read_location.v1': "Allows reading of a character's active ship location.",
|
|
21
|
+
'esi-location.read_ship_type.v1': "Allows reading of a character's active ship class.",
|
|
22
|
+
'esi-skills.read_skillqueue.v1': "Allows reading of a character's currently training skill queue.",
|
|
23
|
+
'esi-skills.read_skills.v1': "Allows reading of a character's currently known skills.",
|
|
24
|
+
'esi-universe.read_structures.v1':
|
|
25
|
+
"Allows querying the location and type of structures that the character has docking access at.",
|
|
26
|
+
'esi-calendar.respond_calendar_events.v1': "Allows updating of a character's calendar event responses.",
|
|
27
|
+
'esi-search.search_structures.v1':
|
|
28
|
+
"Allows searching over all structures that a character can see in the structure browser.",
|
|
29
|
+
'esi-fleets.write_fleet.v1': "Allows manipulating fleets.",
|
|
30
|
+
'esi-ui.write_waypoint.v1': "Allows manipulating waypoints in game client remotely."
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def generate_scopes(apps, schema_editor):
|
|
35
|
+
Scope = apps.get_model('esi', 'Scope')
|
|
36
|
+
for s in SCOPES:
|
|
37
|
+
Scope.objects.update_or_create(name=s, defaults={'help_text': SCOPES[s]})
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def delete_scopes(apps, schema_editor):
|
|
41
|
+
Scope = apps.get_model('esi', 'Scope')
|
|
42
|
+
for s in SCOPES:
|
|
43
|
+
try:
|
|
44
|
+
Scope.objects.get(name=s).delete()
|
|
45
|
+
except Scope.DoesNotExist:
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Migration(migrations.Migration):
|
|
50
|
+
dependencies = [
|
|
51
|
+
('esi', '0001_initial'),
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
operations = [
|
|
55
|
+
migrations.RunPython(generate_scopes, delete_scopes)
|
|
56
|
+
]
|