From 939c57434bd599a21c043b5bbef396bd3c26207f Mon Sep 17 00:00:00 2001 From: Tuomas Suutari Date: Tue, 27 Jun 2017 10:04:25 +0300 Subject: [PATCH] Implement API tokens * Remove ApiScope.get_data_for_request and CombinedApiScopeData, since they are not needed anymore. * Add oidc_apis.api_tokens containing get_api_tokens_by_access_token function. * Move the OIDC Claims classes to oidc_apis * Remove TunnistamoTokenModule. It's not needed anymore, since we don't have to modify the ID Token. * Add an endpoint for getting the API tokens. * Do also some minor clean-ups. --- oidc_apis/api_tokens.py | 73 ++++++++++++++++++++ oidc_apis/models.py | 49 +------------- oidc_apis/scopes.py | 108 +++++++++++++++++++++++++++++ oidc_apis/utils.py | 15 ++++ oidc_apis/views.py | 22 ++++++ tunnistamo/oidc.py | 147 ---------------------------------------- tunnistamo/settings.py | 3 +- tunnistamo/urls.py | 2 + 8 files changed, 223 insertions(+), 196 deletions(-) create mode 100644 oidc_apis/api_tokens.py create mode 100644 oidc_apis/scopes.py create mode 100644 oidc_apis/utils.py create mode 100644 oidc_apis/views.py diff --git a/oidc_apis/api_tokens.py b/oidc_apis/api_tokens.py new file mode 100644 index 00000000..4e10af8f --- /dev/null +++ b/oidc_apis/api_tokens.py @@ -0,0 +1,73 @@ +import datetime +from collections import defaultdict + +from django.utils import timezone +from oidc_provider.lib.utils.token import create_id_token, encode_id_token + +from .models import ApiScope +from .scopes import get_userinfo_by_scopes + + +def get_api_tokens_by_access_token(token, request=None): + """ + Get API Tokens for given Access Token. + + :type token: oidc_provider.models.Token + :param token: Access Token to get the API tokens for + + :type request: django.http.HttpRequest|None + :param request: Optional request object for resolving issuer URLs + + :rtype: dict[str,str] + :return: Dictionary of the API tokens with API identifer as the key + """ + # Limit scopes to known and allowed API scopes + known_api_scopes = ApiScope.objects.by_identifiers(token.scope) + allowed_api_scopes = known_api_scopes.allowed_for_client(token.client) + + # Group API scopes by the API identifiers + scopes_by_api = defaultdict(list) + for api_scope in allowed_api_scopes: + scopes_by_api[api_scope.api.identifier].append(api_scope) + + return { + api_identifier: generate_api_token(scopes, token, request) + for (api_identifier, scopes) in scopes_by_api.items() + } + + +def generate_api_token(api_scopes, token, request=None): + assert api_scopes + api = api_scopes[0].api + req_scopes = api.required_scopes + userinfo = get_userinfo_by_scopes(token.user, req_scopes, token.client) + id_token = create_id_token(token.user, aud=api.identifier, request=request) + payload = {} + payload.update(userinfo) + payload.update(id_token) + payload.update(_get_api_authorization_claims(api_scopes)) + payload['exp'] = _get_api_token_expires_at(token) + return encode_id_token(payload, token.client) + + +def _get_api_authorization_claims(api_scopes): + claims = defaultdict(list) + for api_scope in api_scopes: + field = api_scope.api.domain.identifier + claims[field].append(api_scope.relative_identifier) + return dict(claims) + + +def _get_api_token_expires_at(token): + # TODO: Should API tokens have a separate expire time? + return int(_datetime_to_timestamp(token.expires_at)) + + +def _datetime_to_timestamp(dt): + if timezone.is_naive(dt): + tz = timezone.get_default_timezone() + dt = timezone.make_aware(dt, tz) + return (dt - _EPOCH).total_seconds() + + +_EPOCH = datetime.datetime.utcfromtimestamp(0).replace(tzinfo=timezone.utc) diff --git a/oidc_apis/models.py b/oidc_apis/models.py index d7c6ee53..45ec4049 100644 --- a/oidc_apis/models.py +++ b/oidc_apis/models.py @@ -1,5 +1,3 @@ -from collections import defaultdict - from django.core.validators import RegexValidator from django.db import models from django.utils.translation import ugettext_lazy as _ @@ -136,54 +134,11 @@ def _generate_identifier(self): suffix=('.' + self.specifier if self.specifier else '') ) - @classmethod - def get_data_for_request(cls, scopes, client=None): - assert isinstance(scopes, (list, set)), repr(scopes) - assert client is None or isinstance(client, models.Model), repr(client) - known_api_scopes = cls.objects.by_identifiers(scopes) - allowed_api_scopes = ( - known_api_scopes.allowed_for_client(client) if client - else known_api_scopes) - return CombinedApiScopeData(allowed_api_scopes) - - -class CombinedApiScopeData(object): - """ - API scope data combined from several ApiScope objects. - """ - def __init__(self, api_scopes): - self.api_scopes = api_scopes - self.apis = {api_scope.api for api_scope in self.api_scopes} - - @property - def required_scopes(self): - """ - The scopes required by the APIs. - """ - return set(sum((list(api.required_scopes) for api in self.apis), [])) - - @property - def audiences(self): - """ - The audiences for the APIs, for ID token "aud" field. - """ - return sorted(api.identifier for api in self.apis) - - @property - def authorization_claims(self): - """ - API scope authorization fields for the claims dictionary. - """ - authorization_claims = defaultdict(list) - for api_scope in self.api_scopes: - field = api_scope.api.domain.identifier - authorization_claims[field].append(api_scope.relative_identifier) - return dict(authorization_claims) - class ApiScopeTranslation(TranslatedFieldsModel): master = models.ForeignKey( - ApiScope, related_name='translations', null=True, on_delete=models.CASCADE, + ApiScope, related_name='translations', null=True, + on_delete=models.CASCADE, verbose_name=_("API scope")) name = models.CharField( max_length=200, verbose_name=_("name")) diff --git a/oidc_apis/scopes.py b/oidc_apis/scopes.py new file mode 100644 index 00000000..08e6e87a --- /dev/null +++ b/oidc_apis/scopes.py @@ -0,0 +1,108 @@ +from django.utils.translation import ugettext_lazy as _ +from oidc_provider.lib.claims import ScopeClaims, StandardScopeClaims + +from .models import ApiScope +from .utils import combine_uniquely + + +class ApiScopeClaims(ScopeClaims): + @classmethod + def get_scopes_info(cls, scopes=[]): + scopes_by_identifier = { + api_scope.identifier: api_scope + for api_scope in ApiScope.objects.by_identifiers(scopes) + } + api_scopes = (scopes_by_identifier.get(scope) for scope in scopes) + return [ + { + 'scope': api_scope.identifier, + 'name': api_scope.name, + 'description': api_scope.description, + } + for api_scope in api_scopes if api_scope + ] + + +class GithubUsernameScopeClaims(ScopeClaims): + info_github_username = ( + _("GitHub username"), _("Access to your GitHub username.")) + + def scope_github_username(self): + social_accounts = self.user.socialaccount_set + github_account = social_accounts.filter(provider='github').first() + if not github_account: + return {} + github_data = github_account.extra_data + return { + 'github_username': github_data.get('login'), + } + + +class CombinedScopeClaims(ScopeClaims): + combined_scope_claims = [ + StandardScopeClaims, + GithubUsernameScopeClaims, + ApiScopeClaims, + ] + + @classmethod + def get_scopes_info(cls, scopes=[]): + extended_scopes = cls._extend_scope(scopes) + scopes_info_map = {} + for claim_cls in cls.combined_scope_claims: + for info in claim_cls.get_scopes_info(extended_scopes): + scopes_info_map[info['scope']] = info + return [ + scopes_info_map[scope] + for scope in extended_scopes + if scope in scopes_info_map + ] + + @classmethod + def _extend_scope(cls, scopes): + required_scopes = cls._get_all_required_scopes_by_api_scopes(scopes) + extended_scopes = combine_uniquely(scopes, sorted(required_scopes)) + return extended_scopes + + @classmethod + def _get_all_required_scopes_by_api_scopes(cls, scopes): + api_scopes = ApiScope.objects.by_identifiers(scopes) + apis = {x.api for x in api_scopes} + return set(sum((list(api.required_scopes) for api in apis), [])) + + def create_response_dic(self): + result = super(CombinedScopeClaims, self).create_response_dic() + token = FakeToken.from_claims(self) + for claim_cls in self.combined_scope_claims: + claim = claim_cls(token) + result.update(claim.create_response_dic()) + return result + + +class FakeToken(object): + """ + Object that adapts a token. + + ScopeClaims constructor needs a token, but really uses just its + user, scope and client attributes. This adapter makes it possible + to create a token like object from those three attributes or from a + claims object (which doesn't store the token) allowing it to be + passed to a ScopeClaims constructor. + """ + def __init__(self, user, scope, client): + self.user = user + self.scope = scope + self.client = client + + @classmethod + def from_claims(cls, claims): + return cls(claims.user, claims.scopes, claims.client) + + +def get_userinfo_by_scopes(user, scopes, client=None): + token = FakeToken(user, scopes, client) + return _get_userinfo_by_token(token) + + +def _get_userinfo_by_token(token): + return CombinedScopeClaims(token).create_response_dic() diff --git a/oidc_apis/utils.py b/oidc_apis/utils.py new file mode 100644 index 00000000..ea2cc275 --- /dev/null +++ b/oidc_apis/utils.py @@ -0,0 +1,15 @@ +from collections import OrderedDict + + +def combine_uniquely(iterable1, iterable2): + """ + Combine unique items of two sequences preserving order. + + :type seq1: Iterable[Any] + :type seq2: Iterable[Any] + :rtype: list[Any] + """ + result = OrderedDict.fromkeys(iterable1) + for item in iterable2: + result[item] = None + return list(result.keys()) diff --git a/oidc_apis/views.py b/oidc_apis/views.py new file mode 100644 index 00000000..fb44a651 --- /dev/null +++ b/oidc_apis/views.py @@ -0,0 +1,22 @@ +from django.http import JsonResponse +from django.views.decorators.http import require_http_methods +from oidc_provider.lib.utils.oauth2 import protected_resource_view + +from .api_tokens import get_api_tokens_by_access_token + + +@require_http_methods(['GET']) +@protected_resource_view(['openid']) +def get_api_tokens_view(request, token, *args, **kwargs): + """ + Get the authorized API Tokens. + + :type token: oidc_provider.models.Token + :rtype: JsonResponse + """ + api_tokens = get_api_tokens_by_access_token(token, request=request) + response = JsonResponse(api_tokens, status=200) + response['Access-Control-Allow-Origin'] = '*' + response['Cache-Control'] = 'no-store' + response['Pragma'] = 'no-cache' + return response diff --git a/tunnistamo/oidc.py b/tunnistamo/oidc.py index 7e37f261..2186f0ee 100644 --- a/tunnistamo/oidc.py +++ b/tunnistamo/oidc.py @@ -1,146 +1,7 @@ -from django.utils.translation import ugettext_lazy as _ -from jwkest.jwt import JWT -from oidc_provider.lib.claims import ScopeClaims, StandardScopeClaims -from oidc_provider.lib.utils.token import TokenModule - -from hkijwt.models import ApiScope - - -class TunnistamoTokenModule(TokenModule): - def create_id_token(self, user, client, nonce='', at_hash='', - request=None, scope=[]): - """ - :type user: users.models.User - :type client: oidc_provider.models.Client - :type nonce: str - :type at_hast: str - :type request: django.http.HttpRequest|None - :type scope: list[str] - """ - payload = super().create_id_token( - user, client, nonce, at_hash, request, scope) - - api_data = ApiScope.get_data_for_request(scope, client) - - extended_scope = set(scope) | set(api_data.required_scopes) - userinfo = get_userinfo_by_scopes(user, extended_scope, client) - payload.update(userinfo) - payload.update(api_data.authorization_claims) - payload['aud'] = [client.client_id] + api_data.audiences - payload['azp'] = client.client_id - return payload - - def client_id_from_id_token(self, id_token): - payload = JWT().unpack(id_token).payload() - # See https://stackoverflow.com/questions/32013835 - azp = payload.get('azp', None) # azp = Authorized Party - aud = payload.get('aud', None) - first_aud = aud[0] if isinstance(aud, list) else aud - return azp if azp else first_aud - - def sub_generator(user): return str(user.uuid) -class GithubUsernameScopeClaims(ScopeClaims): - info_github_username = ( - _("GitHub username"), _("Access to your GitHub username.")) - - def scope_github_username(self): - social_accounts = self.user.socialaccount_set - github_account = social_accounts.filter(provider='github').first() - if not github_account: - return {} - github_data = github_account.extra_data - return { - 'github_username': github_data.get('login'), - } - - -class ApiAuthorizationScopeClaims(ScopeClaims): - @classmethod - def get_scopes_info(cls, scopes=[]): - scopes_by_identifier = { - api_scope.identifier: api_scope - for api_scope in ApiScope.objects.by_identifiers(scopes) - } - api_scopes = (scopes_by_identifier.get(scope) for scope in scopes) - return [ - { - 'scope': api_scope.identifier, - 'name': api_scope.name, - 'description': api_scope.description, - } - for api_scope in api_scopes if api_scope - ] - - def create_response_dic(self): - result = super(ApiAuthorizationScopeClaims, self).create_response_dic() - api_data = ApiScope.get_data_for_request(self.scopes, self.client) - result.update(api_data.authorization_claims) - return result - - -class CombinedScopeClaims(ScopeClaims): - combined_scope_claims = [ - StandardScopeClaims, - GithubUsernameScopeClaims, - ApiAuthorizationScopeClaims, - ] - - @classmethod - def get_scopes_info(cls, scopes=[]): - extended_scopes = cls._extend_scope(scopes) - scopes_info_map = {} - for claim_cls in cls.combined_scope_claims: - for info in claim_cls.get_scopes_info(extended_scopes): - scopes_info_map[info['scope']] = info - return [ - scopes_info_map[scope] - for scope in extended_scopes - if scope in scopes_info_map - ] - - @classmethod - def _extend_scope(cls, scopes): - api_data = ApiScope.get_data_for_request(scopes) - extended_scopes = list(scopes) - for scope in api_data.required_scopes: - if scope not in extended_scopes: - extended_scopes.append(scope) - return extended_scopes - - def create_response_dic(self): - result = super(CombinedScopeClaims, self).create_response_dic() - token = FakeToken.from_claims(self) - token.scope = self._extend_scope(token.scope) - for claim_cls in self.combined_scope_claims: - claim = claim_cls(token) - result.update(claim.create_response_dic()) - return result - - -class FakeToken(object): - """ - Object that adapts a token. - - ScopeClaims constructor needs a token, but really uses just its - user, scope and client attributes. This adapter makes it possible - to create a token like object from those three attributes or from a - claims object (which doesn't store the token) allowing it to be - passed to a ScopeClaims constructor. - """ - def __init__(self, user, scope, client): - self.user = user - self.scope = scope - self.client = client - - @classmethod - def from_claims(cls, claims): - return cls(claims.user, claims.scopes, claims.client) - - def get_userinfo(claims, user): """ Get info about user into given claims dictionary. @@ -186,11 +47,3 @@ def get_userinfo(claims, user): claims['zoneinfo'] = None return claims - - -def get_userinfo_by_scopes(user, scopes, client=None): - token = FakeToken(user, scopes, client) - result = {} - result.update(StandardScopeClaims(token).create_response_dic()) - result.update(CombinedScopeClaims(token).create_response_dic()) - return result diff --git a/tunnistamo/settings.py b/tunnistamo/settings.py index fcd789ed..1084b16a 100644 --- a/tunnistamo/settings.py +++ b/tunnistamo/settings.py @@ -252,8 +252,7 @@ # django-oidc-provider settings for OpenID Connect support OIDC_USERINFO = 'tunnistamo.oidc.get_userinfo' OIDC_IDTOKEN_SUB_GENERATOR = 'tunnistamo.oidc.sub_generator' -OIDC_EXTRA_SCOPE_CLAIMS = 'tunnistamo.oidc.CombinedScopeClaims' -OIDC_TOKEN_MODULE = 'tunnistamo.oidc.TunnistamoTokenModule' +OIDC_EXTRA_SCOPE_CLAIMS = 'oidc_apis.scopes.CombinedScopeClaims' SASS_PROCESSOR_INCLUDE_DIRS = [ os.path.join(BASE_DIR, 'node_modules'), diff --git a/tunnistamo/urls.py b/tunnistamo/urls.py index f8f908b6..23a915b5 100644 --- a/tunnistamo/urls.py +++ b/tunnistamo/urls.py @@ -8,6 +8,7 @@ from django.http import HttpResponse from django.views.defaults import permission_denied +from oidc_apis.views import get_api_tokens_view from users.views import EmailNeededView, LoginView, LogoutView from .api import GetJWTView, UserView @@ -25,6 +26,7 @@ def show_login(request): urlpatterns = [ url(r'^admin/', include(admin.site.urls)), + url(r'^api-tokens/?$', get_api_tokens_view), url(r'^accounts/profile/', show_login), url(r'^accounts/login/', LoginView.as_view()), url(r'^accounts/logout/', LogoutView.as_view()),