diff --git a/Pipfile b/Pipfile index b92198286..6657c01e1 100644 --- a/Pipfile +++ b/Pipfile @@ -15,9 +15,10 @@ requests = "*" celery = "*" redis = "*" mysqlclient = "*" -oauth2_provider = "*" toml = "*" aliyunsdkcore = "*" +jwcrypto = "*" +oauthlib = "*" mkdocstrings = "*" mkdocs = "*" mkdocs-material = "*" diff --git a/api/v1/views/__init__.py b/api/v1/views/__init__.py index b0b93d7aa..71ef3289e 100644 --- a/api/v1/views/__init__.py +++ b/api/v1/views/__init__.py @@ -1 +1 @@ -from . import loginpage, auth, extension_config, register \ No newline at end of file +from . import loginpage, auth, extension_config, register, app \ No newline at end of file diff --git a/api/v1/views/app.py b/api/v1/views/app.py new file mode 100644 index 000000000..8487890db --- /dev/null +++ b/api/v1/views/app.py @@ -0,0 +1,49 @@ +from ninja import Schema +from pydantic import Field +from arkid.core.api import api +from arkid.core.models import App +from django.db import transaction +from arkid.core.translation import gettext_default as _ +from arkid.extension.models import TenantExtensionConfig +from arkid.core.event import Event, register_event, dispatch_event +from arkid.core.extension.app_protocol import create_app_protocol_extension_config_schema +from arkid.core.event import CREATE_APP, UPDATE_APP, DELETE_APP + +register_event(CREATE_APP, _('create app','创建应用')) +register_event(UPDATE_APP, _('update app','修改应用')) +register_event(DELETE_APP, _('delete app','删除应用')) + +class AppConfigSchemaIn(Schema): + pass + +create_app_protocol_extension_config_schema( + AppConfigSchemaIn, +) + + +class AppConfigSchemaOut(Schema): + config_id: str + + +@transaction.atomic +@api.post("/{tenant_id}/app/", response=AppConfigSchemaOut, auth=None) +def create_app_config(request, tenant_id: str, data: AppConfigSchemaIn): + tenant = request.tenant + # 事件分发 + results = dispatch_event(Event(tag=CREATE_APP, tenant=tenant, request=request, data=data)) + for func, (result, extension) in results: + if result: + # 创建config + config = extension.create_tenant_config(tenant, data.config.data.dict()) + # 创建app + app = App() + app.name = data.name + app.url = data.url + app.logo = data.logo + app.type = data.app_type + app.description = data.description + app.config = config + app.tenant_id = tenant_id + app.save() + break + return {"app_id": app.id.hex} \ No newline at end of file diff --git a/api/v1/views/loginpage.py b/api/v1/views/loginpage.py index 2ad69b111..8eb51a17d 100644 --- a/api/v1/views/loginpage.py +++ b/api/v1/views/loginpage.py @@ -6,7 +6,7 @@ from arkid.core.event import Event, register_event, dispatch_event from arkid.core.api import api, operation from arkid.core.models import Tenant -from arkid.core.extension import AuthFactorExtension +from arkid.core.extension.auth_factor import AuthFactorExtension from arkid.core.translation import gettext_default as _ from arkid.core.event import CREATE_LOGIN_PAGE_AUTH_FACTOR, CREATE_LOGIN_PAGE_RULES diff --git a/arkid/core/admin.py b/arkid/core/admin.py index 8c38f3f3d..bf4b428eb 100644 --- a/arkid/core/admin.py +++ b/arkid/core/admin.py @@ -1,3 +1,17 @@ from django.contrib import admin +from .models import( + User, UserGroup, + App, AppGroup, Permission, + Approve, ExpiringToken, TenantConfig, +) + +# admin.site.register(Tenant) +admin.site.register(User) +admin.site.register(UserGroup) +admin.site.register(App) +admin.site.register(AppGroup) +admin.site.register(Permission) +admin.site.register(Approve) +admin.site.register(ExpiringToken) +admin.site.register(TenantConfig) -# Register your models here. diff --git a/arkid/core/event.py b/arkid/core/event.py index 15a6a2f89..5b1fbb73f 100644 --- a/arkid/core/event.py +++ b/arkid/core/event.py @@ -163,3 +163,6 @@ def unlisten_event(tag, func, **kwargs): # events CREATE_LOGIN_PAGE_AUTH_FACTOR = 'CREATE_LOGIN_PAGE_AUTH_FACTOR' CREATE_LOGIN_PAGE_RULES = 'CREATE_LOGIN_PAGE_RULES' +CREATE_APP = 'CREATE_APP' +UPDATE_APP = 'UPDATE_APP' +DELETE_APP = 'DELETE_APP' diff --git a/arkid/core/extension.py b/arkid/core/extension/__init__.py similarity index 68% rename from arkid/core/extension.py rename to arkid/core/extension/__init__.py index 23592ae74..cbdb4a6e4 100644 --- a/arkid/core/extension.py +++ b/arkid/core/extension/__init__.py @@ -198,7 +198,7 @@ def register_front_pages(self, page): core_page.register_front_pages(page) self.front_pages.append(page) - def register_config_schema(self, schema, package=None): + def register_config_schema(self, schema, package=None, **kwargs): # class XxSchema(Schema): # config: schema # package: Literal[package or self.package] # type: ignore @@ -207,7 +207,7 @@ def register_config_schema(self, schema, package=None): fields=['id'], custom_fields=[ ("package", Literal[package or self.package], Field()), # type: ignore - ("data", schema, Field()) + ("config", schema, Field()) ], ) config_schema_map[package or self.package] = new_schema @@ -223,11 +223,11 @@ def get_config_by_id(self, id): return TenantExtensionConfig.objects.get(id=id) def update_tenant_config(self, id, config): - TenantExtensionConfig.objects.get(id=id).update(config=config) + return TenantExtensionConfig.objects.get(id=id).update(config=config) def create_tenant_config(self, tenant, config): ext = ExtensionModel.objects.filter(package=self.package, version=self.version).first() - TenantExtensionConfig.objects.create(tenant=tenant, extension=ext, config=config) + return TenantExtensionConfig.objects.create(tenant=tenant, extension=ext, config=config) def load(self): self.migrate_extension() @@ -253,121 +253,3 @@ def unload(self): if not core_translation.extension_lang_maps[self.lang_code]: core_translation.extension_lang_maps.pop(self.lang_code) core_translation.lang_maps = core_translation.reset_lang_maps() - - -class AuthFactorExtension(Extension): - LOGIN = 'login' - REGISTER = 'register' - RESET_PASSWORD = 'password' - - def load(self): - super().load() - self.auth_event_tag = self.register_event('auth', '认证') - self.listen_event(self.auth_event_tag, self.authenticate) - self.register_event_tag = self.register_event('register', '注册') - self.listen_event(self.register_event_tag, self.register) - self.password_event_tag = self.register_event('password', '重置密码') - self.listen_event(self.password_event_tag, self.reset_password) - self.listen_event(core_event.CREATE_LOGIN_PAGE_AUTH_FACTOR, self.create_response) - - @abstractmethod - def authenticate(self, event, **kwargs): - pass - - def auth_success(self, user): - return user - - def auth_failed(self, event, data): - core_event.remove_event_id(event) - core_event.break_event_loop(data) - - @abstractmethod - def register(self, event, **kwargs): - pass - - @abstractmethod - def reset_password(self, event, **kwargs): - pass - - def create_response(self, event, **kwargs): - self.data = { - self.LOGIN: { - 'forms':[], - 'bottoms':[], - 'expand':{}, - }, - self.REGISTER: { - 'forms':[], - 'bottoms':[], - 'expand':{}, - }, - self.RESET_PASSWORD: { - 'forms':[], - 'bottoms':[], - 'expand':{}, - }, - } - configs = self.get_tenant_configs(event.tenant) - for config in configs: - if config.config.get("login_enabled"): - self.create_login_page(event, config) - if config.config.get("register_enabled"): - self.create_register_page(event, config) - if config.config.get("reset_password_enabled"): - self.create_password_page(event, config) - self.create_other_page(event, config) - return self.data - - def add_page_form(self, config, page_name, label, items, submit_url=None, submit_label=None): - default = { - "login": ("登录", f"/api/v1/auth/?tenant=tenant_id&event_tag={self.auth_event_tag}"), - "register": ("登录", f"/api/v1/register/?tenant=tenant_id&event_tag={self.register_event_tag}"), - "password": ("登录", f"/api/v1/reset_password/?tenant=tenant_id&event_tag={self.password_event_tag}"), - } - if not submit_label: - submit_label, useless = default.get(page_name) - if not submit_url: - useless, submit_url = default.get(page_name) - - items.append({"type": "hidden", "name": "config_id", "value": config.id}) - self.data[page_name]['forms'].append({ - 'label': label, - 'items': items, - 'submit': {'label': submit_label, 'http': {'url': submit_url, 'method': "post"}} - }) - - def add_page_bottoms(self, page_name, bottoms): - self.data[page_name]['bottoms'].append(bottoms) - - def add_page_extend(self, page_name, buttons, title=None): - if not self.data[page_name].get('extend'): - self.data[page_name]['extend'] = {} - - self.data[page_name]['extend']['title'] = title - self.data[page_name]['extend']['buttons'].append(buttons) - - @abstractmethod - def create_login_page(self, event, config): - pass - - @abstractmethod - def create_register_page(self, event, config): - pass - - @abstractmethod - def create_password_page(self, event, config): - pass - - @abstractmethod - def create_other_page(self, event, config): - pass - - def get_current_config(self, event): - config_id = event.request.POST.get('config_id') - return self.get_config_by_id(config_id) - - -class BaseAuthFactorSchema(Schema): - login_enabled: bool = Field(default=True, title=_('login_enabled', '启用登录')) - register_enabled: bool = Field(default=True, title=_('register_enabled', '启用注册')) - reset_password_enabled: bool = Field(default=True, title=_('reset_password_enabled', '启用重置密码')) diff --git a/arkid/core/extension/app_protocol.py b/arkid/core/extension/app_protocol.py new file mode 100644 index 000000000..983d5c7bd --- /dev/null +++ b/arkid/core/extension/app_protocol.py @@ -0,0 +1,91 @@ +from ninja import Schema +from pydantic import Field +from typing import Optional +from abc import abstractmethod +from typing import Union, Literal +from ninja.orm import create_schema +from arkid.core.extension import Extension +from arkid.extension.models import TenantExtensionConfig +from arkid.core.translation import gettext_default as _ +from arkid.core.models import App +from arkid.core import api as core_api, event as core_event + +app_protocol_schema_map = {} + +def create_app_protocol_extension_config_schema(schema_cls, **field_definitions): + """创建应用协议类插件配置的Schema + + schema_cls只接受一个空定义的Schema + Examples: + >>> from ninja import Schema + >>> from pydantic import Field + >>> class ExampleExtensionConfigSchema(Schema): + >>> pass + >>> create_app_protocol_extension_config_schema( + >>> ExampleExtensionConfigSchema, + >>> field_name=( field_type, Field(...) ) + >>> ) + + Args: + schema_cls (ninja.Schema): 需要创建的Schema class + field_definitions (Any): 任意数量的field,格式为: field_name=(field_type, Field(...)) + """ + for schema in app_protocol_schema_map.values(): + core_api.add_fields(schema, **field_definitions) + core_api.add_fields(schema_cls, __root__=(Union[tuple(app_protocol_schema_map.values())], Field(discriminator='app_type'))) # type: ignore + + +class AppProtocolExtension(Extension): + + app_type_map = [] + + def load(self): + super().load() + + self.listen_event(core_event.CREATE_APP, self.filter_event_handler) + self.listen_event(core_event.UPDATE_APP, self.filter_event_handler) + self.listen_event(core_event.DELETE_APP, self.filter_event_handler) + + + def register_config_schema(self, schema, app_type, package=None,**kwargs): + # 父类 + super().register_config_schema(schema, package, **kwargs) + + # app_type = kwargs.get('app_type', None) + # if app_type is None: + # raise Exception('') + new_schema = create_schema(App, + name=self.package+'_config', + exclude=['is_del', 'is_active', 'updated', 'created', 'tenant', 'secret'], + custom_fields=[ + ("app_type", Literal[app_type], Field()), + ("config", schema, Field()) + ], + ) + app_protocol_schema_map[app_type] = new_schema + self.app_type_map.append(app_type) + # + + def filter_event_handler(self, event, **kwargs): + if event.data.app_type in self.app_type_map: + if event.tag == core_event.CREATE_APP: + return self.create_app(event, data.config) + elif event.tag == core_event.UPDATE_APP: + return self.update_app(event, data.config) + elif event.tag == core_event.DELETE_APP: + return self.delete_app(event, data.config) + return False + + + @abstractmethod + def create_app(self, event, config): + pass + + @abstractmethod + def update_app(self, event, config): + pass + + @abstractmethod + def delete_app(self, event, config): + pass + diff --git a/arkid/core/extension/auth_factor.py b/arkid/core/extension/auth_factor.py new file mode 100644 index 000000000..9f2e00890 --- /dev/null +++ b/arkid/core/extension/auth_factor.py @@ -0,0 +1,124 @@ + +from ninja import Schema +from pydantic import Field +from abc import abstractmethod +from arkid.core.extension import Extension +from arkid.core.translation import gettext_default as _ +from arkid.core import event as core_event + +class AuthFactorExtension(Extension): + LOGIN = 'login' + REGISTER = 'register' + RESET_PASSWORD = 'password' + + def load(self): + super().load() + self.auth_event_tag = self.register_event('auth', '认证') + self.listen_event(self.auth_event_tag, self.authenticate) + self.register_event_tag = self.register_event('register', '注册') + self.listen_event(self.register_event_tag, self.register) + self.password_event_tag = self.register_event('password', '重置密码') + self.listen_event(self.password_event_tag, self.reset_password) + self.listen_event(core_event.CREATE_LOGIN_PAGE_AUTH_FACTOR, self.create_response) + + @abstractmethod + def authenticate(self, event, **kwargs): + pass + + def auth_success(self, user): + return user + + def auth_failed(self, event, data): + core_event.remove_event_id(event) + core_event.break_event_loop(data) + + @abstractmethod + def register(self, event, **kwargs): + pass + + @abstractmethod + def reset_password(self, event, **kwargs): + pass + + def create_response(self, event, **kwargs): + self.data = { + self.LOGIN: { + 'forms':[], + 'bottoms':[], + 'expand':{}, + }, + self.REGISTER: { + 'forms':[], + 'bottoms':[], + 'expand':{}, + }, + self.RESET_PASSWORD: { + 'forms':[], + 'bottoms':[], + 'expand':{}, + }, + } + configs = self.get_tenant_configs(event.tenant) + for config in configs: + if config.config.get("login_enabled"): + self.create_login_page(event, config) + if config.config.get("register_enabled"): + self.create_register_page(event, config) + if config.config.get("reset_password_enabled"): + self.create_password_page(event, config) + self.create_other_page(event, config) + return self.data + + def add_page_form(self, config, page_name, label, items, submit_url=None, submit_label=None): + default = { + "login": ("登录", f"/api/v1/auth/?tenant=tenant_id&event_tag={self.auth_event_tag}"), + "register": ("登录", f"/api/v1/register/?tenant=tenant_id&event_tag={self.register_event_tag}"), + "password": ("登录", f"/api/v1/reset_password/?tenant=tenant_id&event_tag={self.password_event_tag}"), + } + if not submit_label: + submit_label, useless = default.get(page_name) + if not submit_url: + useless, submit_url = default.get(page_name) + + items.append({"type": "hidden", "name": "config_id", "value": config.id}) + self.data[page_name]['forms'].append({ + 'label': label, + 'items': items, + 'submit': {'label': submit_label, 'http': {'url': submit_url, 'method': "post"}} + }) + + def add_page_bottoms(self, page_name, bottoms): + self.data[page_name]['bottoms'].append(bottoms) + + def add_page_extend(self, page_name, buttons, title=None): + if not self.data[page_name].get('extend'): + self.data[page_name]['extend'] = {} + + self.data[page_name]['extend']['title'] = title + self.data[page_name]['extend']['buttons'].append(buttons) + + @abstractmethod + def create_login_page(self, event, config): + pass + + @abstractmethod + def create_register_page(self, event, config): + pass + + @abstractmethod + def create_password_page(self, event, config): + pass + + @abstractmethod + def create_other_page(self, event, config): + pass + + def get_current_config(self, event): + config_id = event.request.POST.get('config_id') + return self.get_config_by_id(config_id) + + +class BaseAuthFactorSchema(Schema): + login_enabled: bool = Field(default=True, title=_('login_enabled', '启用登录')) + register_enabled: bool = Field(default=True, title=_('register_enabled', '启用注册')) + reset_password_enabled: bool = Field(default=True, title=_('reset_password_enabled', '启用重置密码')) diff --git a/arkid/core/migrations/0003_remove_app_data.py b/arkid/core/migrations/0003_remove_app_data.py new file mode 100644 index 000000000..1ad2422e3 --- /dev/null +++ b/arkid/core/migrations/0003_remove_app_data.py @@ -0,0 +1,17 @@ +# Generated by Django 4.0.3 on 2022-04-18 09:14 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0002_alter_expiringtoken_token_alter_expiringtoken_user'), + ] + + operations = [ + migrations.RemoveField( + model_name='app', + name='data', + ), + ] diff --git a/arkid/core/migrations/0004_remove_app_type.py b/arkid/core/migrations/0004_remove_app_type.py new file mode 100644 index 000000000..8e312237e --- /dev/null +++ b/arkid/core/migrations/0004_remove_app_type.py @@ -0,0 +1,17 @@ +# Generated by Django 4.0.3 on 2022-04-18 09:18 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0003_remove_app_data'), + ] + + operations = [ + migrations.RemoveField( + model_name='app', + name='type', + ), + ] diff --git a/arkid/core/migrations/0005_app_config.py b/arkid/core/migrations/0005_app_config.py new file mode 100644 index 000000000..bbc4edac8 --- /dev/null +++ b/arkid/core/migrations/0005_app_config.py @@ -0,0 +1,20 @@ +# Generated by Django 4.0.3 on 2022-04-18 09:32 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('extension', '0001_initial'), + ('core', '0004_remove_app_type'), + ] + + operations = [ + migrations.AddField( + model_name='app', + name='config', + field=models.OneToOneField(default=None, on_delete=django.db.models.deletion.PROTECT, to='extension.tenantextensionconfig'), + ), + ] diff --git a/arkid/core/migrations/0006_app_type.py b/arkid/core/migrations/0006_app_type.py new file mode 100644 index 000000000..f1b1d4b96 --- /dev/null +++ b/arkid/core/migrations/0006_app_type.py @@ -0,0 +1,18 @@ +# Generated by Django 4.0.3 on 2022-04-18 10:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0005_app_config'), + ] + + operations = [ + migrations.AddField( + model_name='app', + name='type', + field=models.CharField(default='', max_length=128, verbose_name='type'), + ), + ] diff --git a/arkid/core/models.py b/arkid/core/models.py index 1d6c5f513..09ed47be0 100644 --- a/arkid/core/models.py +++ b/arkid/core/models.py @@ -5,6 +5,7 @@ from arkid.core.translation import gettext_default as _ # from oauth2_provider.generators import generate_client_secret from arkid.core.expand import ExpandManager, ExpandModel +from arkid.extension.models import TenantExtensionConfig from arkid.core.token import generate_token @@ -98,11 +99,13 @@ class Meta(object): url = models.CharField(max_length=1024, blank=True, verbose_name=_('url','地址')) logo = models.CharField(max_length=1024, blank=True, null=True, default='', verbose_name=_('logo','图标')) description = models.TextField(blank=True, null=True, verbose_name=_('description','描述')) - type = models.CharField(max_length=128, verbose_name=_('type','类型')) - data = models.JSONField(blank=True, default=dict, verbose_name=_('data','配置')) + type = models.CharField(max_length=128, default='', verbose_name=_('type','类型')) secret = models.CharField( max_length=255, blank=True, null=True, default='', verbose_name=_('secret','密钥') ) + config = models.OneToOneField( + TenantExtensionConfig, blank=False, default=None, on_delete=models.PROTECT + ) def __str__(self) -> str: return f'Tenant: {self.tenant.name}, App: {self.name}' diff --git a/arkid/settings.py b/arkid/settings.py index a92bc0eaa..1898bd6ee 100644 --- a/arkid/settings.py +++ b/arkid/settings.py @@ -38,6 +38,7 @@ 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', + 'oauth2_provider', 'arkid.core', 'arkid.extension', ] diff --git a/extension_root/com_longgui_oauth2_server/__init__.py b/extension_root/com_longgui_oauth2_server/__init__.py new file mode 100644 index 000000000..22ed55d0f --- /dev/null +++ b/extension_root/com_longgui_oauth2_server/__init__.py @@ -0,0 +1,115 @@ +from arkid.core.extension.app_protocol import AppProtocolExtension +from .appscheme import ( + Oauth2ConfigSchema, OIDCConfigSchema, +) +from oauth2_provider.models import Application +from arkid.config import get_app_config + +class OAuth2ServerExtension(AppProtocolExtension): + + def load(self): + super().load() + # 加载url地址 + self.load_urls() + # 加载相应的配置文件 + self.register_config_schema(OIDCConfigSchema, 'OIDC', self.package) + self.register_config_schema(Oauth2ConfigSchema, 'OAuth2' ,self.package) + + + def load_urls(self): + from django.urls import include, re_path + + urls = [ + re_path(r'^o/', include('oauth2_provider.urls')) + ] + + self.register_routers(urls, True) + + def create_app(self, tenant, app, data): + + client_type = data.get('client_type') + skip_authorization = data.get('skip_authorization') + redirect_uris = data.get('redirect_uris') + authorization_grant_type = data.get('grant_type') + algorithm = data.get('algorithm') + host = get_app_config().get_frontend_host() + + obj = Application() + obj.name = app.id + obj.client_type = client_type + obj.skip_authorization = skip_authorization + obj.redirect_uris = redirect_uris + if algorithm and app.type == 'OIDC': + obj.algorithm = algorithm + obj.authorization_grant_type = authorization_grant_type + obj.save() + + uniformed_data = { + 'client_type': client_type, + 'redirect_uris': redirect_uris, + 'grant_type': authorization_grant_type, + 'client_id': obj.client_id, + 'client_secret': obj.client_secret, + 'skip_authorization': obj.skip_authorization, + 'userinfo': host+reverse("api:oauth2_authorization_server:oauth-user-info", args=[tenant.uuid]), + 'authorize': host+reverse("api:oauth2_authorization_server:authorize", args=[tenant.uuid]), + 'token': host+reverse("api:oauth2_authorization_server:token", args=[tenant.uuid]), + } + if algorithm and app.type == 'OIDC': + uniformed_data['algorithm'] = obj.algorithm + uniformed_data['logout'] = host+reverse("api:oauth2_authorization_server:oauth-user-logout", args=[tenant.uuid]) + + app.data = uniformed_data + app.save() + + def update(self, tenant, app, data): + client_type = data.get('client_type') + redirect_uris = data.get('redirect_uris') + skip_authorization = data.get('skip_authorization') + authorization_grant_type = data.get('grant_type') + algorithm = data.get('algorithm') + host = get_app_config().get_host() + obj = Application.objects.filter(name=app.id).first() + obj.client_type = client_type + obj.redirect_uris = redirect_uris + obj.skip_authorization = skip_authorization + obj.authorization_grant_type = authorization_grant_type + if algorithm and app.type == 'OIDC': + obj.algorithm = algorithm + obj.save() + uniformed_data = { + 'client_type': client_type, + 'redirect_uris': redirect_uris, + 'grant_type': authorization_grant_type, + 'client_id': obj.client_id, + 'client_secret': obj.client_secret, + 'skip_authorization': obj.skip_authorization, + 'userinfo': host+reverse("api:oauth2_authorization_server:oauth-user-info", args=[tenant.uuid]), + 'authorize': host+reverse("api:oauth2_authorization_server:authorize", args=[tenant.uuid]), + 'token': host+reverse("api:oauth2_authorization_server:token", args=[tenant.uuid]), + } + if algorithm and app.type == 'OIDC': + uniformed_data['algorithm'] = obj.algorithm + uniformed_data['logout'] = host+reverse("api:oauth2_authorization_server:oauth-user-logout", args=[tenant.uuid]) + + app.data = uniformed_data + app.save() + + def create_app(self, event, config): + return True + + def update_app(self, event, config): + return True + + def delete_app(self, event, config): + return True + +extension = OAuth2ServerExtension( + package='com.longgui.oauth2_server', + description='OAuth2认证服务', + version='1.0', + labels='oauth', + homepage='https://www.longguikeji.com', + logo='', + author='hanbin@jinji-inc.com', +) \ No newline at end of file diff --git a/extension_root/com_longgui_oauth2_server/appscheme.py b/extension_root/com_longgui_oauth2_server/appscheme.py new file mode 100644 index 000000000..e3ecdbea6 --- /dev/null +++ b/extension_root/com_longgui_oauth2_server/appscheme.py @@ -0,0 +1,65 @@ +from enum import Enum +from ninja import Schema +from oauth2_provider.models import Application +from arkid.core.translation import gettext_default as _ +from typing import Optional +from pydantic import Field + + +class CLIENT_TYPE(str, Enum): + confidential = _('Confidential', '私密') + public = _('Public','公开') + + +class GRANT_TYPE(str, Enum): + authorization_code = _('Authorization code', '私密') + implicit = _('Implicit','公开') + password = _('Resource owner password based','密码') + client_credentials = _('Client credentials','客户端凭据') + openid_hybrid = _('OpenID connect hybrid','OpenID链接') + + +class ConfigBaseSchema(Schema): + + skip_authorization: bool = Field(title=_('skip authorization', '是否跳过验证'), default=False) + redirect_uris: str = Field(title=_('redirect uris', '回调地址')) + client_type: CLIENT_TYPE = Field(title=_('client type','客户端是否公开')) + grant_type: GRANT_TYPE = Field(title=_('type','授权类型')) + + +class Oauth2ConfigSchema(ConfigBaseSchema): + + # 输出的比输入的额外多了一些字段 + client_id: str = Field(title=_('client id','客户端id'), readonly=True) + client_secret: str = Field(title=_('client secret','客户端密钥'), readonly=True) + authorize: str = Field(title=_('authorize','授权url'), readonly=True) + token: str = Field(title=_('token','获取token地址'), readonly=True) + userinfo: str = Field(title=_('userinfo','用户信息地址'), readonly=True) + logout: str = Field(title=_('logout', '退出登录地址'), readonly=True) + + +# class OAuth2AppSchema(AppBaseSchema): + +# data: Oauth2ConfigSchema = Field(title=_('data', '数据')) + + +class ALGORITHM_TYPE(str, Enum): + + RS256 = _('RSA with SHA-2 256','RS256加密') + HS256 = _('HMAC with SHA-2 256','HS256加密') + + +class OIDCConfigSchema(ConfigBaseSchema): + + algorithm: ALGORITHM_TYPE = Field(title=_('algorithm','加密类型')) + client_id: str = Field(title=_('client id','客户端id'), readonly=True) + client_secret: str = Field(title=_('client secret','客户端密钥'), readonly=True) + authorize: str = Field(title=_('authorize','授权url'), readonly=True) + token: str = Field(title=_('token','获取token地址'), readonly=True) + userinfo: str = Field(title=_('userinfo','用户信息地址'), readonly=True) + logout: str = Field(title=_('logout', '退出登录地址'), readonly=True) + + +# class OIDCAppSchema(AppBaseSchema): + +# data: OIDCConfigSchema = Field(title=_('data', '数据')) diff --git a/extension_root/com_longgui_oauth2_server/constants.py b/extension_root/com_longgui_oauth2_server/constants.py new file mode 100644 index 000000000..749e96394 --- /dev/null +++ b/extension_root/com_longgui_oauth2_server/constants.py @@ -0,0 +1 @@ +KEY = "oauth2_server" \ No newline at end of file diff --git a/extension_root/com_longgui_password_auth_factor/__init__.py b/extension_root/com_longgui_password_auth_factor/__init__.py index e2931db44..76752d28d 100644 --- a/extension_root/com_longgui_password_auth_factor/__init__.py +++ b/extension_root/com_longgui_password_auth_factor/__init__.py @@ -1,6 +1,6 @@ from distutils import core import re -from arkid.core.extension import AuthFactorExtension, BaseAuthFactorSchema +from arkid.core.extension.auth_factor import AuthFactorExtension, BaseAuthFactorSchema from arkid.core.error import ErrorCode from arkid.core.models import User from .models import UserPassword diff --git a/oauth2_provider/__init__.py b/oauth2_provider/__init__.py new file mode 100644 index 000000000..e7c862d98 --- /dev/null +++ b/oauth2_provider/__init__.py @@ -0,0 +1,4 @@ +import pkg_resources + + +default_app_config = "oauth2_provider.apps.DOTConfig" diff --git a/oauth2_provider/admin.py b/oauth2_provider/admin.py new file mode 100644 index 000000000..43ccbe241 --- /dev/null +++ b/oauth2_provider/admin.py @@ -0,0 +1,63 @@ +from django.contrib import admin + +from oauth2_provider.models import ( + get_access_token_admin_class, + get_access_token_model, + get_application_admin_class, + get_application_model, + get_grant_admin_class, + get_grant_model, + get_id_token_admin_class, + get_id_token_model, + get_refresh_token_admin_class, + get_refresh_token_model, +) + + +class ApplicationAdmin(admin.ModelAdmin): + list_display = ("id", "name", "user", "client_type", "authorization_grant_type") + list_filter = ("client_type", "authorization_grant_type", "skip_authorization") + radio_fields = { + "client_type": admin.HORIZONTAL, + "authorization_grant_type": admin.VERTICAL, + } + raw_id_fields = ("user",) + + +class AccessTokenAdmin(admin.ModelAdmin): + list_display = ("token", "user", "application", "expires") + raw_id_fields = ("user", "source_refresh_token") + + +class GrantAdmin(admin.ModelAdmin): + list_display = ("code", "application", "user", "expires") + raw_id_fields = ("user",) + + +class IDTokenAdmin(admin.ModelAdmin): + list_display = ("token", "user", "application", "expires") + raw_id_fields = ("user",) + + +class RefreshTokenAdmin(admin.ModelAdmin): + list_display = ("token", "user", "application") + raw_id_fields = ("user", "access_token") + + +application_model = get_application_model() +access_token_model = get_access_token_model() +grant_model = get_grant_model() +id_token_model = get_id_token_model() +refresh_token_model = get_refresh_token_model() + +application_admin_class = get_application_admin_class() +access_token_admin_class = get_access_token_admin_class() +grant_admin_class = get_grant_admin_class() +id_token_admin_class = get_id_token_admin_class() +refresh_token_admin_class = get_refresh_token_admin_class() + +admin.site.register(application_model, application_admin_class) +admin.site.register(access_token_model, access_token_admin_class) +admin.site.register(grant_model, grant_admin_class) +admin.site.register(id_token_model, id_token_admin_class) +admin.site.register(refresh_token_model, refresh_token_admin_class) diff --git a/oauth2_provider/apps.py b/oauth2_provider/apps.py new file mode 100644 index 000000000..887e4e3fb --- /dev/null +++ b/oauth2_provider/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class DOTConfig(AppConfig): + name = "oauth2_provider" + verbose_name = "Django OAuth Toolkit" diff --git a/oauth2_provider/backends.py b/oauth2_provider/backends.py new file mode 100644 index 000000000..3f6fab9af --- /dev/null +++ b/oauth2_provider/backends.py @@ -0,0 +1,26 @@ +from django.contrib.auth import get_user_model + +from .oauth2_backends import get_oauthlib_core + + +UserModel = get_user_model() +OAuthLibCore = get_oauthlib_core() + + +class OAuth2Backend: + """ + Authenticate against an OAuth2 access token + """ + + def authenticate(self, request=None, **credentials): + if request is not None: + valid, r = OAuthLibCore.verify_request(request, scopes=[]) + if valid: + return r.user + return None + + def get_user(self, user_id): + try: + return UserModel.objects.get(pk=user_id) + except UserModel.DoesNotExist: + return None diff --git a/oauth2_provider/compat.py b/oauth2_provider/compat.py new file mode 100644 index 000000000..0c83cb37a --- /dev/null +++ b/oauth2_provider/compat.py @@ -0,0 +1,4 @@ +""" +The `compat` module provides support for backwards compatibility with older +versions of Django and Python. +""" diff --git a/oauth2_provider/contrib/__init__.py b/oauth2_provider/contrib/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/oauth2_provider/contrib/rest_framework/__init__.py b/oauth2_provider/contrib/rest_framework/__init__.py new file mode 100644 index 000000000..b54f42220 --- /dev/null +++ b/oauth2_provider/contrib/rest_framework/__init__.py @@ -0,0 +1,9 @@ +# flake8: noqa +from .authentication import OAuth2Authentication +from .permissions import ( + IsAuthenticatedOrTokenHasScope, + TokenHasReadWriteScope, + TokenHasResourceScope, + TokenHasScope, + TokenMatchesOASRequirements, +) diff --git a/oauth2_provider/contrib/rest_framework/authentication.py b/oauth2_provider/contrib/rest_framework/authentication.py new file mode 100644 index 000000000..53087f756 --- /dev/null +++ b/oauth2_provider/contrib/rest_framework/authentication.py @@ -0,0 +1,46 @@ +from collections import OrderedDict + +from rest_framework.authentication import BaseAuthentication + +from ...oauth2_backends import get_oauthlib_core + + +class OAuth2Authentication(BaseAuthentication): + """ + OAuth 2 authentication backend using `django-oauth-toolkit` + """ + + www_authenticate_realm = "api" + + def _dict_to_string(self, my_dict): + """ + Return a string of comma-separated key-value pairs (e.g. k="v",k2="v2"). + """ + return ",".join(['{k}="{v}"'.format(k=k, v=v) for k, v in my_dict.items()]) + + def authenticate(self, request): + """ + Returns two-tuple of (user, token) if authentication succeeds, + or None otherwise. + """ + oauthlib_core = get_oauthlib_core() + valid, r = oauthlib_core.verify_request(request, scopes=[]) + if valid: + return r.user, r.access_token + request.oauth2_error = getattr(r, "oauth2_error", {}) + return None + + def authenticate_header(self, request): + """ + Bearer is the only finalized type currently + """ + www_authenticate_attributes = OrderedDict( + [ + ("realm", self.www_authenticate_realm), + ] + ) + oauth2_error = getattr(request, "oauth2_error", {}) + www_authenticate_attributes.update(oauth2_error) + return "Bearer {attributes}".format( + attributes=self._dict_to_string(www_authenticate_attributes), + ) diff --git a/oauth2_provider/contrib/rest_framework/permissions.py b/oauth2_provider/contrib/rest_framework/permissions.py new file mode 100644 index 000000000..1050bf751 --- /dev/null +++ b/oauth2_provider/contrib/rest_framework/permissions.py @@ -0,0 +1,183 @@ +import logging + +from django.core.exceptions import ImproperlyConfigured +from rest_framework.exceptions import PermissionDenied +from rest_framework.permissions import SAFE_METHODS, BasePermission, IsAuthenticated + +from ...settings import oauth2_settings +from .authentication import OAuth2Authentication + + +log = logging.getLogger("oauth2_provider") + + +class TokenHasScope(BasePermission): + """ + The request is authenticated as a user and the token used has the right scope + """ + + def has_permission(self, request, view): + token = request.auth + + if not token: + return False + + if hasattr(token, "scope"): # OAuth 2 + required_scopes = self.get_scopes(request, view) + log.debug("Required scopes to access resource: {0}".format(required_scopes)) + + if token.is_valid(required_scopes): + return True + + # Provide information about required scope? + include_required_scope = ( + oauth2_settings.ERROR_RESPONSE_WITH_SCOPES + and required_scopes + and not token.is_expired() + and not token.allow_scopes(required_scopes) + ) + + if include_required_scope: + self.message = { + "detail": PermissionDenied.default_detail, + "required_scopes": list(required_scopes), + } + + return False + + assert False, ( + "TokenHasScope requires the" + "`oauth2_provider.rest_framework.OAuth2Authentication` authentication " + "class to be used." + ) + + def get_scopes(self, request, view): + try: + return getattr(view, "required_scopes") + except AttributeError: + raise ImproperlyConfigured( + "TokenHasScope requires the view to define the required_scopes attribute" + ) + + +class TokenHasReadWriteScope(TokenHasScope): + """ + The request is authenticated as a user and the token used has the right scope + """ + + def get_scopes(self, request, view): + try: + required_scopes = super().get_scopes(request, view) + except ImproperlyConfigured: + required_scopes = [] + + # TODO: code duplication!! see dispatch in ReadWriteScopedResourceMixin + if request.method.upper() in SAFE_METHODS: + read_write_scope = oauth2_settings.READ_SCOPE + else: + read_write_scope = oauth2_settings.WRITE_SCOPE + + return required_scopes + [read_write_scope] + + +class TokenHasResourceScope(TokenHasScope): + """ + The request is authenticated as a user and the token used has the right scope + """ + + def get_scopes(self, request, view): + try: + view_scopes = super().get_scopes(request, view) + except ImproperlyConfigured: + view_scopes = [] + + if request.method.upper() in SAFE_METHODS: + scope_type = oauth2_settings.READ_SCOPE + else: + scope_type = oauth2_settings.WRITE_SCOPE + + required_scopes = ["{}:{}".format(scope, scope_type) for scope in view_scopes] + + return required_scopes + + +class IsAuthenticatedOrTokenHasScope(BasePermission): + """ + The user is authenticated using some backend or the token has the right scope + This only returns True if the user is authenticated, but not using a token + or using a token, and the token has the correct scope. + + This is usefull when combined with the DjangoModelPermissions to allow people browse + the browsable api's if they log in using the a non token bassed middleware, + and let them access the api's using a rest client with a token + """ + + def has_permission(self, request, view): + is_authenticated = IsAuthenticated().has_permission(request, view) + oauth2authenticated = False + if is_authenticated: + oauth2authenticated = isinstance(request.successful_authenticator, OAuth2Authentication) + + token_has_scope = TokenHasScope() + return (is_authenticated and not oauth2authenticated) or token_has_scope.has_permission(request, view) + + +class TokenMatchesOASRequirements(BasePermission): + """ + :attr:alternate_required_scopes: dict keyed by HTTP method name with value: iterable alternate scope lists + + This fulfills the [Open API Specification (OAS; formerly Swagger)](https://www.openapis.org/) + list of alternative Security Requirements Objects for oauth2 or openIdConnect: + When a list of Security Requirement Objects is defined on the Open API object or Operation Object, + only one of Security Requirement Objects in the list needs to be satisfied to authorize the request. + [1](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#securityRequirementObject) + + For each method, a list of lists of allowed scopes is tried in order and the first to match succeeds. + + @example + required_alternate_scopes = { + 'GET': [['read']], + 'POST': [['create1','scope2'], ['alt-scope3'], ['alt-scope4','alt-scope5']], + } + + TODO: DRY: subclass TokenHasScope and iterate over values of required_scope? + """ + + def has_permission(self, request, view): + token = request.auth + + if not token: + return False + + if hasattr(token, "scope"): # OAuth 2 + required_alternate_scopes = self.get_required_alternate_scopes(request, view) + + m = request.method.upper() + if m in required_alternate_scopes: + log.debug( + "Required scopes alternatives to access resource: {0}".format( + required_alternate_scopes[m] + ) + ) + for alt in required_alternate_scopes[m]: + if token.is_valid(alt): + return True + return False + else: + log.warning("no scope alternates defined for method {0}".format(m)) + return False + + assert False, ( + "TokenMatchesOASRequirements requires the" + "`oauth2_provider.rest_framework.OAuth2Authentication` authentication " + "class to be used." + ) + + def get_required_alternate_scopes(self, request, view): + try: + return getattr(view, "required_alternate_scopes") + except AttributeError: + raise ImproperlyConfigured( + "TokenMatchesOASRequirements requires the view to" + " define the required_alternate_scopes attribute" + ) diff --git a/oauth2_provider/decorators.py b/oauth2_provider/decorators.py new file mode 100644 index 000000000..0ab26ddb4 --- /dev/null +++ b/oauth2_provider/decorators.py @@ -0,0 +1,87 @@ +from functools import wraps + +from django.core.exceptions import ImproperlyConfigured +from django.http import HttpResponseForbidden +from oauthlib.oauth2 import Server + +from .oauth2_backends import OAuthLibCore +from .oauth2_validators import OAuth2Validator +from .scopes import get_scopes_backend +from .settings import oauth2_settings + + +def protected_resource(scopes=None, validator_cls=OAuth2Validator, server_cls=Server): + """ + Decorator to protect views by providing OAuth2 authentication out of the box, + optionally with scope handling. + + @protected_resource() + def my_view(request): + # An access token is required to get here... + # ... + pass + """ + _scopes = scopes or [] + + def decorator(view_func): + @wraps(view_func) + def _validate(request, *args, **kwargs): + validator = validator_cls() + core = OAuthLibCore(server_cls(validator)) + valid, oauthlib_req = core.verify_request(request, scopes=_scopes) + if valid: + request.resource_owner = oauthlib_req.user + return view_func(request, *args, **kwargs) + return HttpResponseForbidden() + + return _validate + + return decorator + + +def rw_protected_resource(scopes=None, validator_cls=OAuth2Validator, server_cls=Server): + """ + Decorator to protect views by providing OAuth2 authentication and read/write scopes + out of the box. + GET, HEAD, OPTIONS http methods require "read" scope. Otherwise "write" scope is required. + + @rw_protected_resource() + def my_view(request): + # If this is a POST, you have to provide 'write' scope to get here... + # ... + pass + + """ + _scopes = scopes or [] + + def decorator(view_func): + @wraps(view_func) + def _validate(request, *args, **kwargs): + # Check if provided scopes are acceptable + provided_scopes = get_scopes_backend().get_all_scopes() + read_write_scopes = [oauth2_settings.READ_SCOPE, oauth2_settings.WRITE_SCOPE] + + if not set(read_write_scopes).issubset(set(provided_scopes)): + raise ImproperlyConfigured( + "rw_protected_resource decorator requires following scopes {0}" + " to be in OAUTH2_PROVIDER['SCOPES'] list in settings".format(read_write_scopes) + ) + + # Check if method is safe + if request.method.upper() in ["GET", "HEAD", "OPTIONS"]: + _scopes.append(oauth2_settings.READ_SCOPE) + else: + _scopes.append(oauth2_settings.WRITE_SCOPE) + + # proceed with validation + validator = validator_cls() + core = OAuthLibCore(server_cls(validator)) + valid, oauthlib_req = core.verify_request(request, scopes=_scopes) + if valid: + request.resource_owner = oauthlib_req.user + return view_func(request, *args, **kwargs) + return HttpResponseForbidden() + + return _validate + + return decorator diff --git a/oauth2_provider/exceptions.py b/oauth2_provider/exceptions.py new file mode 100644 index 000000000..c4208488d --- /dev/null +++ b/oauth2_provider/exceptions.py @@ -0,0 +1,19 @@ +class OAuthToolkitError(Exception): + """ + Base class for exceptions + """ + + def __init__(self, error=None, redirect_uri=None, *args, **kwargs): + super().__init__(*args, **kwargs) + self.oauthlib_error = error + + if redirect_uri: + self.oauthlib_error.redirect_uri = redirect_uri + + +class FatalClientError(OAuthToolkitError): + """ + Class for critical errors + """ + + pass diff --git a/oauth2_provider/forms.py b/oauth2_provider/forms.py new file mode 100644 index 000000000..876213626 --- /dev/null +++ b/oauth2_provider/forms.py @@ -0,0 +1,14 @@ +from django import forms + + +class AllowForm(forms.Form): + allow = forms.BooleanField(required=False) + redirect_uri = forms.CharField(widget=forms.HiddenInput()) + scope = forms.CharField(widget=forms.HiddenInput()) + nonce = forms.CharField(required=False, widget=forms.HiddenInput()) + client_id = forms.CharField(widget=forms.HiddenInput()) + state = forms.CharField(required=False, widget=forms.HiddenInput()) + response_type = forms.CharField(widget=forms.HiddenInput()) + code_challenge = forms.CharField(required=False, widget=forms.HiddenInput()) + code_challenge_method = forms.CharField(required=False, widget=forms.HiddenInput()) + claims = forms.CharField(required=False, widget=forms.HiddenInput()) diff --git a/oauth2_provider/generators.py b/oauth2_provider/generators.py new file mode 100644 index 000000000..f72bc6e7a --- /dev/null +++ b/oauth2_provider/generators.py @@ -0,0 +1,45 @@ +from oauthlib.common import UNICODE_ASCII_CHARACTER_SET +from oauthlib.common import generate_client_id as oauthlib_generate_client_id + +from .settings import oauth2_settings + + +class BaseHashGenerator: + """ + All generators should extend this class overriding `.hash()` method. + """ + + def hash(self): + raise NotImplementedError() + + +class ClientIdGenerator(BaseHashGenerator): + def hash(self): + """ + Generate a client_id for Basic Authentication scheme without colon char + as in http://tools.ietf.org/html/rfc2617#section-2 + """ + return oauthlib_generate_client_id(length=40, chars=UNICODE_ASCII_CHARACTER_SET) + + +class ClientSecretGenerator(BaseHashGenerator): + def hash(self): + length = oauth2_settings.CLIENT_SECRET_GENERATOR_LENGTH + chars = UNICODE_ASCII_CHARACTER_SET + return oauthlib_generate_client_id(length=length, chars=chars) + + +def generate_client_id(): + """ + Generate a suitable client id + """ + client_id_generator = oauth2_settings.CLIENT_ID_GENERATOR_CLASS() + return client_id_generator.hash() + + +def generate_client_secret(): + """ + Generate a suitable client secret + """ + client_secret_generator = oauth2_settings.CLIENT_SECRET_GENERATOR_CLASS() + return client_secret_generator.hash() diff --git a/oauth2_provider/http.py b/oauth2_provider/http.py new file mode 100644 index 000000000..274ed81af --- /dev/null +++ b/oauth2_provider/http.py @@ -0,0 +1,32 @@ +from urllib.parse import urlparse + +from django.core.exceptions import DisallowedRedirect +from django.http import HttpResponse +from django.utils.encoding import iri_to_uri + + +class OAuth2ResponseRedirect(HttpResponse): + """ + An HTTP 302 redirect with an explicit list of allowed schemes. + Works like django.http.HttpResponseRedirect but we customize it + to give us more flexibility on allowed scheme validation. + """ + + status_code = 302 + + def __init__(self, redirect_to, allowed_schemes, *args, **kwargs): + super().__init__(*args, **kwargs) + self["Location"] = iri_to_uri(redirect_to) + self.allowed_schemes = allowed_schemes + self.validate_redirect(redirect_to) + + @property + def url(self): + return self["Location"] + + def validate_redirect(self, redirect_to): + parsed = urlparse(str(redirect_to)) + if not parsed.scheme: + raise DisallowedRedirect("OAuth2 redirects require a URI scheme.") + if parsed.scheme not in self.allowed_schemes: + raise DisallowedRedirect("Redirect to scheme {!r} is not permitted".format(parsed.scheme)) diff --git a/oauth2_provider/locale/pt/LC_MESSAGES/django.po b/oauth2_provider/locale/pt/LC_MESSAGES/django.po new file mode 100644 index 000000000..0f111d991 --- /dev/null +++ b/oauth2_provider/locale/pt/LC_MESSAGES/django.po @@ -0,0 +1,167 @@ +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2019-01-25 11:45+0000\n" +"PO-Revision-Date: 2019-01-25 11:45+0000\n" +"Last-Translator: Sandro Rodrigues \n" +"Language-Team: LANGUAGE \n" +"Language: pt-PT\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: docs/_build/html/_sources/templates.rst.txt:94 +#: oauth2_provider/templates/oauth2_provider/authorize.html:8 +#: oauth2_provider/templates/oauth2_provider/authorize.html:30 +msgid "Authorize" +msgstr "Autorizar" + +#: docs/_build/html/_sources/templates.rst.txt:103 +#: oauth2_provider/templates/oauth2_provider/authorize.html:17 +msgid "Application requires following permissions" +msgstr "A aplicação requer as seguintes permissões" + +#: oauth2_provider/models.py:41 +msgid "Confidential" +msgstr "Confidencial" + +#: oauth2_provider/models.py:42 +msgid "Public" +msgstr "Público" + +#: oauth2_provider/models.py:50 +msgid "Authorization code" +msgstr "Código de autorização" + +#: oauth2_provider/models.py:51 +msgid "Implicit" +msgstr "Implícito" + +#: oauth2_provider/models.py:52 +msgid "Resource owner password-based" +msgstr "Palavra-passe do proprietário de dados" + +#: oauth2_provider/models.py:53 +msgid "Client credentials" +msgstr "Credenciais do cliente" + +#: oauth2_provider/models.py:67 +msgid "Allowed URIs list, space separated" +msgstr "Lista de URIs permitidos, separados por espaço" + +#: oauth2_provider/models.py:143 +#, python-brace-format +msgid "Unauthorized redirect scheme: {scheme}" +msgstr "Esquema de redirecionamento não autorizado: {scheme}" + +#: oauth2_provider/models.py:148 +#, python-brace-format +msgid "redirect_uris cannot be empty with grant_type {grant_type}" +msgstr "redirect_uris não pode estar vazio com o grant_type {grant_type}" + +#: oauth2_provider/oauth2_validators.py:166 +msgid "The access token is invalid." +msgstr "O token de acesso é inválido." + +#: oauth2_provider/oauth2_validators.py:171 +msgid "The access token has expired." +msgstr "O token de acesso expirou." + +#: oauth2_provider/oauth2_validators.py:176 +msgid "The access token is valid but does not have enough scope." +msgstr "O token de acesso é válido, mas não tem permissões suficientes." + +#: oauth2_provider/templates/oauth2_provider/application_confirm_delete.html:6 +msgid "Are you sure to delete the application" +msgstr "Tem a certeza que pretende apagar a aplicação" + +#: oauth2_provider/templates/oauth2_provider/application_confirm_delete.html:12 +#: oauth2_provider/templates/oauth2_provider/authorize.html:29 +msgid "Cancel" +msgstr "Cancelar" + +#: oauth2_provider/templates/oauth2_provider/application_confirm_delete.html:13 +#: oauth2_provider/templates/oauth2_provider/application_detail.html:38 +#: oauth2_provider/templates/oauth2_provider/authorized-token-delete.html:7 +msgid "Delete" +msgstr "Apagar" + +#: oauth2_provider/templates/oauth2_provider/application_detail.html:10 +msgid "Client id" +msgstr "ID do Cliente" + +#: oauth2_provider/templates/oauth2_provider/application_detail.html:15 +msgid "Client secret" +msgstr "Segredo do cliente" + +#: oauth2_provider/templates/oauth2_provider/application_detail.html:20 +msgid "Client type" +msgstr "Tipo de cliente" + +#: oauth2_provider/templates/oauth2_provider/application_detail.html:25 +msgid "Authorization Grant Type" +msgstr "Tipo de concessão de autorização" + +#: oauth2_provider/templates/oauth2_provider/application_detail.html:30 +msgid "Redirect Uris" +msgstr "URI's de redirecionamento" + +#: oauth2_provider/templates/oauth2_provider/application_detail.html:36 +#: oauth2_provider/templates/oauth2_provider/application_form.html:35 +msgid "Go Back" +msgstr "Voltar" + +#: oauth2_provider/templates/oauth2_provider/application_detail.html:37 +msgid "Edit" +msgstr "Editar" + +#: oauth2_provider/templates/oauth2_provider/application_form.html:9 +msgid "Edit application" +msgstr "Editar aplicação" + +#: oauth2_provider/templates/oauth2_provider/application_form.html:37 +msgid "Save" +msgstr "Guardar" + +#: oauth2_provider/templates/oauth2_provider/application_list.html:6 +msgid "Your applications" +msgstr "As tuas aplicações" + +#: oauth2_provider/templates/oauth2_provider/application_list.html:14 +msgid "New Application" +msgstr "Nova Aplicação" + +#: oauth2_provider/templates/oauth2_provider/application_list.html:17 +msgid "No applications defined" +msgstr "Sem aplicações definidas" + +#: oauth2_provider/templates/oauth2_provider/application_list.html:17 +msgid "Click here" +msgstr "Clica aqui" + +#: oauth2_provider/templates/oauth2_provider/application_list.html:17 +msgid "if you want to register a new one" +msgstr "se pretender registar uma nova" + +#: oauth2_provider/templates/oauth2_provider/application_registration_form.html:5 +msgid "Register a new application" +msgstr "Registar nova aplicação" + +#: oauth2_provider/templates/oauth2_provider/authorized-token-delete.html:6 +msgid "Are you sure you want to delete this token?" +msgstr "Tem a certeza que pretende apagar o token?" + +#: oauth2_provider/templates/oauth2_provider/authorized-tokens.html:6 +msgid "Tokens" +msgstr "Tokens" + +#: oauth2_provider/templates/oauth2_provider/authorized-tokens.html:11 +msgid "revoke" +msgstr "revogar" + +#: oauth2_provider/templates/oauth2_provider/authorized-tokens.html:19 +msgid "There are no authorized tokens yet." +msgstr "De momento, não tem tokens autorizados." diff --git a/oauth2_provider/management/__init__.py b/oauth2_provider/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/oauth2_provider/management/commands/__init__.py b/oauth2_provider/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/oauth2_provider/management/commands/cleartokens.py b/oauth2_provider/management/commands/cleartokens.py new file mode 100644 index 000000000..3fb1827f6 --- /dev/null +++ b/oauth2_provider/management/commands/cleartokens.py @@ -0,0 +1,10 @@ +from django.core.management.base import BaseCommand + +from ...models import clear_expired + + +class Command(BaseCommand): + help = "Can be run as a cronjob or directly to clean out expired tokens" + + def handle(self, *args, **options): + clear_expired() diff --git a/oauth2_provider/management/commands/createapplication.py b/oauth2_provider/management/commands/createapplication.py new file mode 100644 index 000000000..92c4ae46b --- /dev/null +++ b/oauth2_provider/management/commands/createapplication.py @@ -0,0 +1,81 @@ +from django.core.exceptions import ValidationError +from django.core.management.base import BaseCommand + +from oauth2_provider.models import get_application_model + + +Application = get_application_model() + + +class Command(BaseCommand): + help = "Shortcut to create a new application in a programmatic way" + + def add_arguments(self, parser): + parser.add_argument( + "client_type", + type=str, + help="The client type, can be confidential or public", + ) + parser.add_argument( + "authorization_grant_type", + type=str, + help="The type of authorization grant to be used", + ) + parser.add_argument( + "--client-id", + type=str, + help="The ID of the new application", + ) + parser.add_argument( + "--user", + type=str, + help="The user the application belongs to", + ) + parser.add_argument( + "--redirect-uris", + type=str, + help="The redirect URIs, this must be a space separated string e.g 'URI1 URI2'", + ) + parser.add_argument( + "--client-secret", + type=str, + help="The secret for this application", + ) + parser.add_argument( + "--name", + type=str, + help="The name this application", + ) + parser.add_argument( + "--skip-authorization", + action="store_true", + help="The ID of the new application", + ) + + def handle(self, *args, **options): + # Extract all fields related to the application, this will work now and in the future + # and also with custom application models. + application_fields = [field.name for field in Application._meta.fields] + application_data = {} + for key, value in options.items(): + # Data in options must be cleaned because there are unneded key-value like + # verbosity and others. Also do not pass any None to the Application + # instance so default values will be generated for those fields + if key in application_fields and value: + if key == "user": + application_data.update({"user_id": value}) + else: + application_data.update({key: value}) + + new_application = Application(**application_data) + + try: + new_application.full_clean() + except ValidationError as exc: + errors = "\n ".join( + ["- " + err_key + ": " + str(err_value) for err_key, err_value in exc.message_dict.items()] + ) + self.stdout.write(self.style.ERROR("Please correct the following errors:\n %s" % errors)) + else: + new_application.save() + self.stdout.write(self.style.SUCCESS("New application created successfully")) diff --git a/oauth2_provider/middleware.py b/oauth2_provider/middleware.py new file mode 100644 index 000000000..b94cb719f --- /dev/null +++ b/oauth2_provider/middleware.py @@ -0,0 +1,36 @@ +from django.contrib.auth import authenticate +from django.utils.cache import patch_vary_headers +from django.utils.deprecation import MiddlewareMixin + + +class OAuth2TokenMiddleware(MiddlewareMixin): + """ + Middleware for OAuth2 user authentication + + This middleware is able to work along with AuthenticationMiddleware and its behaviour depends + on the order it's processed with. + + If it comes *after* AuthenticationMiddleware and request.user is valid, leave it as is and does + not proceed with token validation. If request.user is the Anonymous user proceeds and try to + authenticate the user using the OAuth2 access token. + + If it comes *before* AuthenticationMiddleware, or AuthenticationMiddleware is not used at all, + tries to authenticate user with the OAuth2 access token and set request.user field. Setting + also request._cached_user field makes AuthenticationMiddleware use that instead of the one from + the session. + + It also adds "Authorization" to the "Vary" header, so that django's cache middleware or a + reverse proxy can create proper cache keys. + """ + + def process_request(self, request): + # do something only if request contains a Bearer token + if request.META.get("HTTP_AUTHORIZATION", "").startswith("Bearer"): + if not hasattr(request, "user") or request.user.is_anonymous: + user = authenticate(request=request) + if user: + request.user = request._cached_user = user + + def process_response(self, request, response): + patch_vary_headers(response, ("Authorization",)) + return response diff --git a/oauth2_provider/migrations/0001_initial.py b/oauth2_provider/migrations/0001_initial.py new file mode 100644 index 000000000..1d1a38e0e --- /dev/null +++ b/oauth2_provider/migrations/0001_initial.py @@ -0,0 +1,105 @@ +from django.conf import settings +import django.db.models.deletion +from django.db import migrations, models + +import oauth2_provider.generators +import oauth2_provider.validators +from oauth2_provider.settings import oauth2_settings + + +class Migration(migrations.Migration): + """ + The following migrations are squashed here: + - 0001_initial.py + - 0002_08_updates.py + - 0003_auto_20160316_1503.py + - 0004_auto_20160525_1623.py + - 0005_auto_20170514_1141.py + - 0006_auto_20171214_2232.py + """ + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL) + ] + + operations = [ + migrations.CreateModel( + name='Application', + fields=[ + ('id', models.BigAutoField(serialize=False, primary_key=True)), + ('client_id', models.CharField(default=oauth2_provider.generators.generate_client_id, unique=True, max_length=100, db_index=True)), + ('redirect_uris', models.TextField(help_text='Allowed URIs list, space separated', blank=True)), + ('client_type', models.CharField(max_length=32, choices=[('confidential', 'Confidential'), ('public', 'Public')])), + ('authorization_grant_type', models.CharField(max_length=32, choices=[('authorization-code', 'Authorization code'), ('implicit', 'Implicit'), ('password', 'Resource owner password-based'), ('client-credentials', 'Client credentials')])), + ('client_secret', models.CharField(default=oauth2_provider.generators.generate_client_secret, max_length=255, db_index=True, blank=True)), + ('name', models.CharField(max_length=255, blank=True)), + ('user', models.ForeignKey(related_name="oauth2_provider_application", blank=True, to=settings.AUTH_USER_MODEL, null=True, on_delete=models.CASCADE)), + ('skip_authorization', models.BooleanField(default=False)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ], + options={ + 'abstract': False, + 'swappable': 'OAUTH2_PROVIDER_APPLICATION_MODEL', + }, + ), + migrations.CreateModel( + name='AccessToken', + fields=[ + ('id', models.BigAutoField(serialize=False, primary_key=True)), + ('token', models.CharField(unique=True, max_length=255)), + ('expires', models.DateTimeField()), + ('scope', models.TextField(blank=True)), + ('application', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=oauth2_settings.APPLICATION_MODEL)), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='oauth2_provider_accesstoken', to=settings.AUTH_USER_MODEL)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + # Circular reference. Can't add it here. + #('source_refresh_token', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=oauth2_settings.REFRESH_TOKEN_MODEL, related_name="refreshed_access_token")), + ], + options={ + 'abstract': False, + 'swappable': 'OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL', + }, + ), + migrations.CreateModel( + name='Grant', + fields=[ + ('id', models.BigAutoField(serialize=False, primary_key=True)), + ('code', models.CharField(unique=True, max_length=255)), + ('expires', models.DateTimeField()), + ('redirect_uri', models.CharField(max_length=255)), + ('scope', models.TextField(blank=True)), + ('application', models.ForeignKey(to=oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='oauth2_provider_grant', to=settings.AUTH_USER_MODEL)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ], + options={ + 'abstract': False, + 'swappable': 'OAUTH2_PROVIDER_GRANT_MODEL', + }, + ), + migrations.CreateModel( + name='RefreshToken', + fields=[ + ('id', models.BigAutoField(serialize=False, primary_key=True)), + ('token', models.CharField(max_length=255)), + ('access_token', models.OneToOneField(blank=True, null=True, related_name="refresh_token", to=oauth2_settings.ACCESS_TOKEN_MODEL, on_delete=models.SET_NULL)), + ('application', models.ForeignKey(to=oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='oauth2_provider_refreshtoken', to=settings.AUTH_USER_MODEL)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('revoked', models.DateTimeField(null=True)), + ], + options={ + 'abstract': False, + 'swappable': 'OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL', + 'unique_together': set([("token", "revoked")]), + }, + ), + migrations.AddField( + model_name='AccessToken', + name='source_refresh_token', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=oauth2_settings.REFRESH_TOKEN_MODEL, related_name="refreshed_access_token"), + ), + ] diff --git a/oauth2_provider/migrations/0002_auto_20190406_1805.py b/oauth2_provider/migrations/0002_auto_20190406_1805.py new file mode 100644 index 000000000..8ca177abf --- /dev/null +++ b/oauth2_provider/migrations/0002_auto_20190406_1805.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2 on 2019-04-06 18:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('oauth2_provider', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='grant', + name='code_challenge', + field=models.CharField(blank=True, default='', max_length=128), + ), + migrations.AddField( + model_name='grant', + name='code_challenge_method', + field=models.CharField(blank=True, choices=[('plain', 'plain'), ('S256', 'S256')], default='', max_length=10), + ), + ] diff --git a/oauth2_provider/migrations/0003_auto_20201211_1314.py b/oauth2_provider/migrations/0003_auto_20201211_1314.py new file mode 100644 index 000000000..2787d51a3 --- /dev/null +++ b/oauth2_provider/migrations/0003_auto_20201211_1314.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.4 on 2020-12-11 13:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('oauth2_provider', '0002_auto_20190406_1805'), + ] + + operations = [ + migrations.AlterField( + model_name='grant', + name='redirect_uri', + field=models.TextField(), + ), + ] diff --git a/oauth2_provider/migrations/0004_auto_20200902_2022.py b/oauth2_provider/migrations/0004_auto_20200902_2022.py new file mode 100644 index 000000000..31508bfcb --- /dev/null +++ b/oauth2_provider/migrations/0004_auto_20200902_2022.py @@ -0,0 +1,58 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + +from oauth2_provider.settings import oauth2_settings + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('oauth2_provider', '0003_auto_20201211_1314'), + ] + + operations = [ + migrations.AddField( + model_name='application', + name='algorithm', + field=models.CharField(choices=[("", "No OIDC support"), ('RS256', 'RSA with SHA-2 256'), ('HS256', 'HMAC with SHA-2 256')], default='', max_length=5), + ), + migrations.AlterField( + model_name='application', + name='authorization_grant_type', + field=models.CharField(choices=[('authorization-code', 'Authorization code'), ('implicit', 'Implicit'), ('password', 'Resource owner password-based'), ('client-credentials', 'Client credentials'), ('openid-hybrid', 'OpenID connect hybrid')], max_length=32), + ), + migrations.CreateModel( + name='IDToken', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('token', models.CharField(unique=True, max_length=256)), + ('expires', models.DateTimeField()), + ('scope', models.TextField(blank=True)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('application', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=oauth2_settings.APPLICATION_MODEL)), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='oauth2_provider_idtoken', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + 'swappable': 'OAUTH2_PROVIDER_ID_TOKEN_MODEL', + }, + ), + migrations.AddField( + model_name='accesstoken', + name='id_token', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='access_token', to=oauth2_settings.ID_TOKEN_MODEL), + ), + migrations.AddField( + model_name="grant", + name="nonce", + field=models.CharField(blank=True, max_length=255, default=""), + ), + migrations.AddField( + model_name="grant", + name="claims", + field=models.TextField(blank=True), + ), + ] diff --git a/oauth2_provider/migrations/0005_auto_20210217_1340.py b/oauth2_provider/migrations/0005_auto_20210217_1340.py new file mode 100644 index 000000000..a927a100d --- /dev/null +++ b/oauth2_provider/migrations/0005_auto_20210217_1340.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.6 on 2021-02-17 13:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('oauth2_provider', '0004_auto_20200902_2022'), + ] + + operations = [ + migrations.AlterField( + model_name='application', + name='algorithm', + field=models.CharField(blank=True, choices=[('', 'No OIDC support'), ('RS256', 'RSA with SHA-2 256'), ('HS256', 'HMAC with SHA-2 256')], default='', max_length=5), + ), + ] diff --git a/oauth2_provider/migrations/0006_alter_idtoken_token.py b/oauth2_provider/migrations/0006_alter_idtoken_token.py new file mode 100644 index 000000000..0fd072cb6 --- /dev/null +++ b/oauth2_provider/migrations/0006_alter_idtoken_token.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2 on 2021-04-27 07:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('oauth2_provider', '0005_auto_20210217_1340'), + ] + + operations = [ + migrations.AlterField( + model_name='idtoken', + name='token', + field=models.CharField(max_length=1024), + ), + ] diff --git a/oauth2_provider/migrations/0007_application_custom_template.py b/oauth2_provider/migrations/0007_application_custom_template.py new file mode 100644 index 000000000..5c6bd7e04 --- /dev/null +++ b/oauth2_provider/migrations/0007_application_custom_template.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2 on 2021-06-03 09:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('oauth2_provider', '0006_alter_idtoken_token'), + ] + + operations = [ + migrations.AddField( + model_name='application', + name='custom_template', + field=models.TextField(blank=True, help_text='Application custom authorization html page'), + ), + ] diff --git a/oauth2_provider/migrations/0008_auto_20220112_1647.py b/oauth2_provider/migrations/0008_auto_20220112_1647.py new file mode 100644 index 000000000..1913a50bb --- /dev/null +++ b/oauth2_provider/migrations/0008_auto_20220112_1647.py @@ -0,0 +1,24 @@ +# Generated by Django 3.2.8 on 2022-01-12 16:47 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('oauth2_provider', '0007_application_custom_template'), + ] + + operations = [ + migrations.AddField( + model_name='accesstoken', + name='tenant', + field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.PROTECT, to='core.tenant'), + ), + migrations.AddField( + model_name='idtoken', + name='tenant', + field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.PROTECT, to='core.tenant'), + ), + ] diff --git a/oauth2_provider/migrations/0009_grant_tenant.py b/oauth2_provider/migrations/0009_grant_tenant.py new file mode 100644 index 000000000..3b1c9e111 --- /dev/null +++ b/oauth2_provider/migrations/0009_grant_tenant.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.8 on 2022-01-12 16:54 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('oauth2_provider', '0008_auto_20220112_1647'), + ] + + operations = [ + migrations.AddField( + model_name='grant', + name='tenant', + field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.PROTECT, to='core.tenant'), + ), + ] diff --git a/oauth2_provider/migrations/0010_alter_idtoken_token.py b/oauth2_provider/migrations/0010_alter_idtoken_token.py new file mode 100644 index 000000000..674bebd85 --- /dev/null +++ b/oauth2_provider/migrations/0010_alter_idtoken_token.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.8 on 2022-02-06 13:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('oauth2_provider', '0009_grant_tenant'), + ] + + operations = [ + migrations.AlterField( + model_name='idtoken', + name='token', + field=models.TextField(), + ), + ] diff --git a/oauth2_provider/migrations/0011_alter_accesstoken_user_alter_application_user_and_more.py b/oauth2_provider/migrations/0011_alter_accesstoken_user_alter_application_user_and_more.py new file mode 100644 index 000000000..0dafbb3fd --- /dev/null +++ b/oauth2_provider/migrations/0011_alter_accesstoken_user_alter_application_user_and_more.py @@ -0,0 +1,41 @@ +# Generated by Django 4.0.3 on 2022-04-14 08:31 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('oauth2_provider', '0010_alter_idtoken_token'), + ] + + operations = [ + migrations.AlterField( + model_name='accesstoken', + name='user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='application', + name='user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='grant', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='idtoken', + name='user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='refreshtoken', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/oauth2_provider/migrations/__init__.py b/oauth2_provider/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py new file mode 100644 index 000000000..f8725d5d3 --- /dev/null +++ b/oauth2_provider/models.py @@ -0,0 +1,715 @@ +import json +import logging +from datetime import timedelta +from urllib.parse import parse_qsl, urlparse + +from django.apps import apps +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured +from django.db import models, transaction +from django.urls import reverse +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ +from jwcrypto import jwk, jwt +from jwcrypto.common import base64url_encode + +from .generators import generate_client_id, generate_client_secret +from .scopes import get_scopes_backend +from .settings import oauth2_settings +from .validators import RedirectURIValidator, WildcardSet + +from arkid.core.models import Tenant + + +logger = logging.getLogger(__name__) + + +class AbstractApplication(models.Model): + """ + An Application instance represents a Client on the Authorization server. + Usually an Application is created manually by client's developers after + logging in on an Authorization Server. + + Fields: + + * :attr:`client_id` The client identifier issued to the client during the + registration process as described in :rfc:`2.2` + * :attr:`user` ref to a Django user + * :attr:`redirect_uris` The list of allowed redirect uri. The string + consists of valid URLs separated by space + * :attr:`client_type` Client type as described in :rfc:`2.1` + * :attr:`authorization_grant_type` Authorization flows available to the + Application + * :attr:`client_secret` Confidential secret issued to the client during + the registration process as described in :rfc:`2.2` + * :attr:`name` Friendly name for the Application + """ + + CLIENT_CONFIDENTIAL = "confidential" + CLIENT_PUBLIC = "public" + CLIENT_TYPES = ( + (CLIENT_CONFIDENTIAL, _("Confidential")), + (CLIENT_PUBLIC, _("Public")), + ) + + GRANT_AUTHORIZATION_CODE = "authorization-code" + GRANT_IMPLICIT = "implicit" + GRANT_PASSWORD = "password" + GRANT_CLIENT_CREDENTIALS = "client-credentials" + GRANT_OPENID_HYBRID = "openid-hybrid" + GRANT_TYPES = ( + (GRANT_AUTHORIZATION_CODE, _("Authorization code")), + (GRANT_IMPLICIT, _("Implicit")), + (GRANT_PASSWORD, _("Resource owner password-based")), + (GRANT_CLIENT_CREDENTIALS, _("Client credentials")), + (GRANT_OPENID_HYBRID, _("OpenID connect hybrid")), + ) + + NO_ALGORITHM = "" + RS256_ALGORITHM = "RS256" + HS256_ALGORITHM = "HS256" + ALGORITHM_TYPES = ( + (NO_ALGORITHM, _("No OIDC support")), + (RS256_ALGORITHM, _("RSA with SHA-2 256")), + (HS256_ALGORITHM, _("HMAC with SHA-2 256")), + ) + + id = models.BigAutoField(primary_key=True) + client_id = models.CharField(max_length=100, unique=True, default=generate_client_id, db_index=True) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + related_name="%(app_label)s_%(class)s", + null=True, + blank=True, + on_delete=models.CASCADE, + ) + + redirect_uris = models.TextField( + blank=True, + help_text=_("Allowed URIs list, space separated"), + ) + client_type = models.CharField(max_length=32, choices=CLIENT_TYPES) + authorization_grant_type = models.CharField(max_length=32, choices=GRANT_TYPES) + client_secret = models.CharField( + max_length=255, blank=True, default=generate_client_secret, db_index=True + ) + name = models.CharField(max_length=255, blank=True) + skip_authorization = models.BooleanField(default=False) + + created = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) + algorithm = models.CharField(max_length=5, choices=ALGORITHM_TYPES, default=NO_ALGORITHM, blank=True) + + class Meta: + abstract = True + + def __str__(self): + return self.name or self.client_id + + @property + def default_redirect_uri(self): + """ + Returns the default redirect_uri extracting the first item from + the :attr:`redirect_uris` string + """ + if self.redirect_uris: + return self.redirect_uris.split().pop(0) + + assert False, ( + "If you are using implicit, authorization_code" + "or all-in-one grant_type, you must define " + "redirect_uris field in your Application model" + ) + + def redirect_uri_allowed(self, uri): + """ + Checks if given url is one of the items in :attr:`redirect_uris` string + + :param uri: Url to check + """ + parsed_uri = urlparse(uri) + uqs_set = set(parse_qsl(parsed_uri.query)) + for allowed_uri in self.redirect_uris.split(): + parsed_allowed_uri = urlparse(allowed_uri) + + if ( + parsed_allowed_uri.scheme == parsed_uri.scheme + and parsed_allowed_uri.netloc == parsed_uri.netloc + and parsed_allowed_uri.path == parsed_uri.path + ): + + aqs_set = set(parse_qsl(parsed_allowed_uri.query)) + + if aqs_set.issubset(uqs_set): + return True + + return False + + def clean(self): + from django.core.exceptions import ValidationError + + grant_types = ( + AbstractApplication.GRANT_AUTHORIZATION_CODE, + AbstractApplication.GRANT_IMPLICIT, + AbstractApplication.GRANT_OPENID_HYBRID, + ) + hs_forbidden_grant_types = ( + AbstractApplication.GRANT_IMPLICIT, + AbstractApplication.GRANT_OPENID_HYBRID, + ) + + redirect_uris = self.redirect_uris.strip().split() + allowed_schemes = set(s.lower() for s in self.get_allowed_schemes()) + + if redirect_uris: + validator = RedirectURIValidator(WildcardSet()) + for uri in redirect_uris: + validator(uri) + scheme = urlparse(uri).scheme + if scheme not in allowed_schemes: + raise ValidationError(_("Unauthorized redirect scheme: {scheme}").format(scheme=scheme)) + + elif self.authorization_grant_type in grant_types: + raise ValidationError( + _("redirect_uris cannot be empty with grant_type {grant_type}").format( + grant_type=self.authorization_grant_type + ) + ) + if self.algorithm == AbstractApplication.RS256_ALGORITHM: + if not oauth2_settings.OIDC_RSA_PRIVATE_KEY: + raise ValidationError(_("You must set OIDC_RSA_PRIVATE_KEY to use RSA algorithm")) + + if self.algorithm == AbstractApplication.HS256_ALGORITHM: + if any( + ( + self.authorization_grant_type in hs_forbidden_grant_types, + self.client_type == Application.CLIENT_PUBLIC, + ) + ): + raise ValidationError(_("You cannot use HS256 with public grants or clients")) + + def get_absolute_url(self): + return reverse("api:detail", args=[str(self.id)]) + + def get_allowed_schemes(self): + """ + Returns the list of redirect schemes allowed by the Application. + By default, returns `ALLOWED_REDIRECT_URI_SCHEMES`. + """ + return oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES + + def allows_grant_type(self, *grant_types): + return self.authorization_grant_type in grant_types + + def is_usable(self, request): + """ + Determines whether the application can be used. + + :param request: The oauthlib.common.Request being processed. + """ + return True + + @property + def jwk_key(self): + if self.algorithm == AbstractApplication.RS256_ALGORITHM: + if not oauth2_settings.OIDC_RSA_PRIVATE_KEY: + raise ImproperlyConfigured("You must set OIDC_RSA_PRIVATE_KEY to use RSA algorithm") + return jwk.JWK.from_pem(oauth2_settings.OIDC_RSA_PRIVATE_KEY.encode("utf8")) + elif self.algorithm == AbstractApplication.HS256_ALGORITHM: + return jwk.JWK(kty="oct", k=base64url_encode(self.client_secret)) + raise ImproperlyConfigured("This application does not support signed tokens") + + +class ApplicationManager(models.Manager): + def get_by_natural_key(self, client_id): + return self.get(client_id=client_id) + + +class Application(AbstractApplication): + objects = ApplicationManager() + + custom_template = models.TextField( + blank=True, + help_text=_("Application custom authorization html page"), + ) + + class Meta(AbstractApplication.Meta): + swappable = "OAUTH2_PROVIDER_APPLICATION_MODEL" + + def natural_key(self): + return (self.client_id,) + + +class AbstractGrant(models.Model): + """ + A Grant instance represents a token with a short lifetime that can + be swapped for an access token, as described in :rfc:`4.1.2` + + Fields: + + * :attr:`user` The Django user who requested the grant + * :attr:`code` The authorization code generated by the authorization server + * :attr:`application` Application instance this grant was asked for + * :attr:`expires` Expire time in seconds, defaults to + :data:`settings.AUTHORIZATION_CODE_EXPIRE_SECONDS` + * :attr:`redirect_uri` Self explained + * :attr:`scope` Required scopes, optional + * :attr:`code_challenge` PKCE code challenge + * :attr:`code_challenge_method` PKCE code challenge transform algorithm + """ + + CODE_CHALLENGE_PLAIN = "plain" + CODE_CHALLENGE_S256 = "S256" + CODE_CHALLENGE_METHODS = ((CODE_CHALLENGE_PLAIN, "plain"), (CODE_CHALLENGE_S256, "S256")) + + id = models.BigAutoField(primary_key=True) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="%(app_label)s_%(class)s" + ) + tenant = models.ForeignKey( + Tenant, + blank=False, + null=True, + default=None, + on_delete=models.PROTECT + ) + code = models.CharField(max_length=255, unique=True) # code comes from oauthlib + application = models.ForeignKey(oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE) + expires = models.DateTimeField() + redirect_uri = models.TextField() + scope = models.TextField(blank=True) + + created = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) + + code_challenge = models.CharField(max_length=128, blank=True, default="") + code_challenge_method = models.CharField( + max_length=10, blank=True, default="", choices=CODE_CHALLENGE_METHODS + ) + + nonce = models.CharField(max_length=255, blank=True, default="") + claims = models.TextField(blank=True) + + def is_expired(self): + """ + Check token expiration with timezone awareness + """ + if not self.expires: + return True + + return timezone.now() >= self.expires + + def redirect_uri_allowed(self, uri): + uri1 = uri + uri2 = self.redirect_uri + index = uri2.find('?') + if index != -1: + uri2 = uri2[0:index] + return uri1 == uri2 + + def __str__(self): + return self.code + + class Meta: + abstract = True + + +class Grant(AbstractGrant): + class Meta(AbstractGrant.Meta): + swappable = "OAUTH2_PROVIDER_GRANT_MODEL" + + +class AbstractAccessToken(models.Model): + """ + An AccessToken instance represents the actual access token to + access user's resources, as in :rfc:`5`. + + Fields: + + * :attr:`user` The Django user representing resources" owner + * :attr:`source_refresh_token` If from a refresh, the consumed RefeshToken + * :attr:`token` Access token + * :attr:`application` Application instance + * :attr:`expires` Date and time of token expiration, in DateTime format + * :attr:`scope` Allowed scopes + """ + + id = models.BigAutoField(primary_key=True) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + blank=True, + null=True, + related_name="%(app_label)s_%(class)s", + ) + tenant = models.ForeignKey( + Tenant, + blank=False, + null=True, + default=None, + on_delete=models.PROTECT + ) + source_refresh_token = models.OneToOneField( + # unique=True implied by the OneToOneField + oauth2_settings.REFRESH_TOKEN_MODEL, + on_delete=models.SET_NULL, + blank=True, + null=True, + related_name="refreshed_access_token", + ) + token = models.CharField( + max_length=255, + unique=True, + ) + id_token = models.OneToOneField( + oauth2_settings.ID_TOKEN_MODEL, + on_delete=models.CASCADE, + blank=True, + null=True, + related_name="access_token", + ) + application = models.ForeignKey( + oauth2_settings.APPLICATION_MODEL, + on_delete=models.CASCADE, + blank=True, + null=True, + ) + expires = models.DateTimeField() + scope = models.TextField(blank=True) + + created = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) + + def is_valid(self, scopes=None): + """ + Checks if the access token is valid. + + :param scopes: An iterable containing the scopes to check or None + """ + return not self.is_expired() + + def is_expired(self): + """ + Check token expiration with timezone awareness + """ + if not self.expires: + return True + + return timezone.now() >= self.expires + + def allow_scopes(self, scopes): + """ + Check if the token allows the provided scopes + + :param scopes: An iterable containing the scopes to check + """ + if not scopes: + return True + provided_scopes = set(self.scope.split()) + resource_scopes = set(scopes) + return resource_scopes.issubset(provided_scopes) + + def revoke(self): + """ + Convenience method to uniform tokens" interface, for now + simply remove this token from the database in order to revoke it. + """ + self.delete() + + @property + def scopes(self): + """ + Returns a dictionary of allowed scope names (as keys) with their descriptions (as values) + """ + all_scopes = get_scopes_backend().get_all_scopes() + token_scopes = self.scope.split() + return {name: desc for name, desc in all_scopes.items() if name in token_scopes} + + def __str__(self): + return self.token + + class Meta: + abstract = True + + +class AccessToken(AbstractAccessToken): + class Meta(AbstractAccessToken.Meta): + swappable = "OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL" + + +class AbstractRefreshToken(models.Model): + """ + A RefreshToken instance represents a token that can be swapped for a new + access token when it expires. + + Fields: + + * :attr:`user` The Django user representing resources" owner + * :attr:`token` Token value + * :attr:`application` Application instance + * :attr:`access_token` AccessToken instance this refresh token is + bounded to + * :attr:`revoked` Timestamp of when this refresh token was revoked + """ + + id = models.BigAutoField(primary_key=True) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="%(app_label)s_%(class)s" + ) + token = models.CharField(max_length=255) + application = models.ForeignKey(oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE) + access_token = models.OneToOneField( + oauth2_settings.ACCESS_TOKEN_MODEL, + on_delete=models.SET_NULL, + blank=True, + null=True, + related_name="refresh_token", + ) + + created = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) + revoked = models.DateTimeField(null=True) + + def revoke(self): + """ + Mark this refresh token revoked and revoke related access token + """ + access_token_model = get_access_token_model() + refresh_token_model = get_refresh_token_model() + with transaction.atomic(): + try: + token = refresh_token_model.objects.select_for_update().filter( + pk=self.pk, revoked__isnull=True + ) + except refresh_token_model.DoesNotExist: + return + if not token: + return + self = list(token)[0] + + try: + access_token_model.objects.get(id=self.access_token_id).revoke() + except access_token_model.DoesNotExist: + pass + self.access_token = None + self.revoked = timezone.now() + self.save() + + def __str__(self): + return self.token + + class Meta: + abstract = True + unique_together = ( + "token", + "revoked", + ) + + +class RefreshToken(AbstractRefreshToken): + class Meta(AbstractRefreshToken.Meta): + swappable = "OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL" + + +class AbstractIDToken(models.Model): + """ + An IDToken instance represents the actual token to + access user's resources, as in :openid:`2`. + + Fields: + + * :attr:`user` The Django user representing resources' owner + * :attr:`token` ID token + * :attr:`application` Application instance + * :attr:`expires` Date and time of token expiration, in DateTime format + * :attr:`scope` Allowed scopes + """ + + id = models.BigAutoField(primary_key=True) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + blank=True, + null=True, + related_name="%(app_label)s_%(class)s", + ) + tenant = models.ForeignKey( + Tenant, + blank=False, + null=True, + default=None, + on_delete=models.PROTECT + ) + token = models.TextField() + application = models.ForeignKey( + oauth2_settings.APPLICATION_MODEL, + on_delete=models.CASCADE, + blank=True, + null=True, + ) + expires = models.DateTimeField() + scope = models.TextField(blank=True) + + created = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) + + def is_valid(self, scopes=None): + """ + Checks if the access token is valid. + + :param scopes: An iterable containing the scopes to check or None + """ + return not self.is_expired() and self.allow_scopes(scopes) + + def is_expired(self): + """ + Check token expiration with timezone awareness + """ + if not self.expires: + return True + + return timezone.now() >= self.expires + + def allow_scopes(self, scopes): + """ + Check if the token allows the provided scopes + + :param scopes: An iterable containing the scopes to check + """ + if not scopes: + return True + + provided_scopes = set(self.scope.split()) + resource_scopes = set(scopes) + + return resource_scopes.issubset(provided_scopes) + + def revoke(self): + """ + Convenience method to uniform tokens' interface, for now + simply remove this token from the database in order to revoke it. + """ + self.delete() + + @property + def scopes(self): + """ + Returns a dictionary of allowed scope names (as keys) with their descriptions (as values) + """ + all_scopes = get_scopes_backend().get_all_scopes() + token_scopes = self.scope.split() + return {name: desc for name, desc in all_scopes.items() if name in token_scopes} + + @property + def claims(self): + jwt_token = jwt.JWT(key=self.application.jwk_key, jwt=self.token) + return json.loads(jwt_token.claims) + + def __str__(self): + return self.token + + class Meta: + abstract = True + + +class IDToken(AbstractIDToken): + class Meta(AbstractIDToken.Meta): + swappable = "OAUTH2_PROVIDER_ID_TOKEN_MODEL" + + +def get_application_model(): + """ Return the Application model that is active in this project. """ + return apps.get_model(oauth2_settings.APPLICATION_MODEL) + + +def get_grant_model(): + """ Return the Grant model that is active in this project. """ + return apps.get_model(oauth2_settings.GRANT_MODEL) + + +def get_access_token_model(): + """ Return the AccessToken model that is active in this project. """ + return apps.get_model(oauth2_settings.ACCESS_TOKEN_MODEL) + + +def get_id_token_model(): + """ Return the AccessToken model that is active in this project. """ + return apps.get_model(oauth2_settings.ID_TOKEN_MODEL) + + +def get_refresh_token_model(): + """ Return the RefreshToken model that is active in this project. """ + return apps.get_model(oauth2_settings.REFRESH_TOKEN_MODEL) + + +def get_application_admin_class(): + """ Return the Application admin class that is active in this project. """ + application_admin_class = oauth2_settings.APPLICATION_ADMIN_CLASS + return application_admin_class + + +def get_access_token_admin_class(): + """ Return the AccessToken admin class that is active in this project. """ + access_token_admin_class = oauth2_settings.ACCESS_TOKEN_ADMIN_CLASS + return access_token_admin_class + + +def get_grant_admin_class(): + """ Return the Grant admin class that is active in this project. """ + grant_admin_class = oauth2_settings.GRANT_ADMIN_CLASS + return grant_admin_class + + +def get_id_token_admin_class(): + """ Return the IDToken admin class that is active in this project. """ + id_token_admin_class = oauth2_settings.ID_TOKEN_ADMIN_CLASS + return id_token_admin_class + + +def get_refresh_token_admin_class(): + """ Return the RefreshToken admin class that is active in this project. """ + refresh_token_admin_class = oauth2_settings.REFRESH_TOKEN_ADMIN_CLASS + return refresh_token_admin_class + + +def clear_expired(): + now = timezone.now() + refresh_expire_at = None + access_token_model = get_access_token_model() + refresh_token_model = get_refresh_token_model() + grant_model = get_grant_model() + REFRESH_TOKEN_EXPIRE_SECONDS = oauth2_settings.REFRESH_TOKEN_EXPIRE_SECONDS + if REFRESH_TOKEN_EXPIRE_SECONDS: + if not isinstance(REFRESH_TOKEN_EXPIRE_SECONDS, timedelta): + try: + REFRESH_TOKEN_EXPIRE_SECONDS = timedelta(seconds=REFRESH_TOKEN_EXPIRE_SECONDS) + except TypeError: + e = "REFRESH_TOKEN_EXPIRE_SECONDS must be either a timedelta or seconds" + raise ImproperlyConfigured(e) + refresh_expire_at = now - REFRESH_TOKEN_EXPIRE_SECONDS + + with transaction.atomic(): + if refresh_expire_at: + revoked = refresh_token_model.objects.filter( + revoked__lt=refresh_expire_at, + ) + expired = refresh_token_model.objects.filter( + access_token__expires__lt=refresh_expire_at, + ) + + logger.info("%s Revoked refresh tokens to be deleted", revoked.count()) + logger.info("%s Expired refresh tokens to be deleted", expired.count()) + + revoked.delete() + expired.delete() + else: + logger.info("refresh_expire_at is %s. No refresh tokens deleted.", refresh_expire_at) + + access_tokens = access_token_model.objects.filter(refresh_token__isnull=True, expires__lt=now) + grants = grant_model.objects.filter(expires__lt=now) + + logger.info("%s Expired access tokens to be deleted", access_tokens.count()) + logger.info("%s Expired grant tokens to be deleted", grants.count()) + + access_tokens.delete() + grants.delete() diff --git a/oauth2_provider/oauth2_backends.py b/oauth2_provider/oauth2_backends.py new file mode 100644 index 000000000..c4f283b85 --- /dev/null +++ b/oauth2_provider/oauth2_backends.py @@ -0,0 +1,229 @@ +import json +from urllib.parse import urlparse, urlunparse + +from oauthlib import oauth2 +from oauthlib.common import Request as OauthlibRequest +from oauthlib.common import quote, urlencode, urlencoded + +from .exceptions import FatalClientError, OAuthToolkitError +from .settings import oauth2_settings + + +class OAuthLibCore: + """ + Wrapper for oauth Server providing django-specific interfaces. + + Meant for things like extracting request data and converting + everything to formats more palatable for oauthlib's Server. + """ + + def __init__(self, server=None): + """ + :params server: An instance of oauthlib.oauth2.Server class + """ + validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS + validator = validator_class() + server_kwargs = oauth2_settings.server_kwargs + self.server = server or oauth2_settings.OAUTH2_SERVER_CLASS(validator, **server_kwargs) + + def _get_escaped_full_path(self, request): + """ + Django considers "safe" some characters that aren't so for oauthlib. + We have to search for them and properly escape. + """ + parsed = list(urlparse(request.get_full_path())) + unsafe = set(c for c in parsed[4]).difference(urlencoded) + for c in unsafe: + parsed[4] = parsed[4].replace(c, quote(c, safe=b"")) + + return urlunparse(parsed) + + def _get_extra_credentials(self, request): + """ + Produce extra credentials for token response. This dictionary will be + merged with the response. + See also: `oauthlib.oauth2.rfc6749.TokenEndpoint.create_token_response` + + :param request: The current django.http.HttpRequest object + :return: dictionary of extra credentials or None (default) + """ + return None + + def _extract_params(self, request): + """ + Extract parameters from the Django request object. + Such parameters will then be passed to OAuthLib to build its own + Request object. The body should be encoded using OAuthLib urlencoded. + """ + uri = self._get_escaped_full_path(request) + http_method = request.method + headers = self.extract_headers(request) + body = urlencode(self.extract_body(request)) + return uri, http_method, body, headers + + def extract_headers(self, request): + """ + Extracts headers from the Django request object + :param request: The current django.http.HttpRequest object + :return: a dictionary with OAuthLib needed headers + """ + headers = request.META.copy() + if "wsgi.input" in headers: + del headers["wsgi.input"] + if "wsgi.errors" in headers: + del headers["wsgi.errors"] + if "HTTP_AUTHORIZATION" in headers: + headers["Authorization"] = headers["HTTP_AUTHORIZATION"] + + return headers + + def extract_body(self, request): + """ + Extracts the POST body from the Django request object + :param request: The current django.http.HttpRequest object + :return: provided POST parameters + """ + return request.POST.items() + + def validate_authorization_request(self, request): + """ + A wrapper method that calls validate_authorization_request on `server_class` instance. + + :param request: The current django.http.HttpRequest object + """ + try: + uri, http_method, body, headers = self._extract_params(request) + scopes, credentials = self.server.validate_authorization_request( + uri, http_method=http_method, body=body, headers=headers + ) + return scopes, credentials + except oauth2.FatalClientError as error: + raise FatalClientError(error=error) + except oauth2.OAuth2Error as error: + raise OAuthToolkitError(error=error) + + def create_authorization_response(self, request, scopes, credentials, allow): + """ + A wrapper method that calls create_authorization_response on `server_class` + instance. + + :param request: The current django.http.HttpRequest object + :param scopes: A list of provided scopes + :param credentials: Authorization credentials dictionary containing + `client_id`, `state`, `redirect_uri`, `response_type` + :param allow: True if the user authorize the client, otherwise False + """ + try: + if not allow: + raise oauth2.AccessDeniedError(state=credentials.get("state", None)) + + # add current user to credentials. this will be used by OAUTH2_VALIDATOR_CLASS + credentials["user"] = request.user + + headers, body, status = self.server.create_authorization_response( + uri=request.get_raw_uri(), scopes=scopes, credentials=credentials + ) + uri = headers.get("Location", None) + + return uri, headers, body, status + + except oauth2.FatalClientError as error: + raise FatalClientError(error=error, redirect_uri=credentials["redirect_uri"]) + except oauth2.OAuth2Error as error: + raise OAuthToolkitError(error=error, redirect_uri=credentials["redirect_uri"]) + + def create_token_response(self, request, tenant): + """ + A wrapper method that calls create_token_response on `server_class` instance. + + :param request: The current django.http.HttpRequest object + """ + uri, http_method, body, headers = self._extract_params(request) + extra_credentials = self._get_extra_credentials(request) + + headers, body, status = self.server.create_token_response( + uri, http_method, body, headers, extra_credentials, + ) + uri = headers.get("Location", None) + return uri, headers, body, status + + def create_revocation_response(self, request): + """ + A wrapper method that calls create_revocation_response on a + `server_class` instance. + + :param request: The current django.http.HttpRequest object + """ + uri, http_method, body, headers = self._extract_params(request) + + headers, body, status = self.server.create_revocation_response(uri, http_method, body, headers) + uri = headers.get("Location", None) + + return uri, headers, body, status + + def create_userinfo_response(self, request): + """ + A wrapper method that calls create_userinfo_response on a + `server_class` instance. + + :param request: The current django.http.HttpRequest object + """ + uri, http_method, body, headers = self._extract_params(request) + headers, body, status = self.server.create_userinfo_response(uri, http_method, body, headers) + uri = headers.get("Location", None) + + return uri, headers, body, status + + def verify_request(self, request, scopes): + """ + A wrapper method that calls verify_request on `server_class` instance. + + :param request: The current django.http.HttpRequest object + :param scopes: A list of scopes required to verify so that request is verified + """ + uri, http_method, body, headers = self._extract_params(request) + + valid, r = self.server.verify_request(uri, http_method, body, headers, scopes=scopes) + return valid, r + + def authenticate_client(self, request): + """Wrapper to call `authenticate_client` on `server_class` instance. + + :param request: The current django.http.HttpRequest object + """ + uri, http_method, body, headers = self._extract_params(request) + oauth_request = OauthlibRequest(uri, http_method, body, headers) + return self.server.request_validator.authenticate_client(oauth_request) + + +class JSONOAuthLibCore(OAuthLibCore): + """ + Extends the default OAuthLibCore to parse correctly application/json requests + """ + + def extract_body(self, request): + """ + Extracts the JSON body from the Django request object + :param request: The current django.http.HttpRequest object + :return: provided POST parameters "urlencodable" + """ + try: + body = json.loads(request.body.decode("utf-8")).items() + except AttributeError: + body = "" + except ValueError: + body = "" + + return body + + +def get_oauthlib_core(): + """ + Utility function that returns an instance of + `oauth2_provider.backends.OAuthLibCore` + """ + validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS + validator = validator_class() + server_kwargs = oauth2_settings.server_kwargs + server = oauth2_settings.OAUTH2_SERVER_CLASS(validator, **server_kwargs) + return oauth2_settings.OAUTH2_BACKEND_CLASS(server) diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py new file mode 100644 index 000000000..dded1be52 --- /dev/null +++ b/oauth2_provider/oauth2_validators.py @@ -0,0 +1,918 @@ +import base64 +import binascii +import http.client +import json +import logging +from collections import OrderedDict +from datetime import datetime, timedelta +from urllib.parse import unquote_plus + +import requests +from django.conf import settings +from django.contrib.auth import authenticate, get_user_model +from django.core.exceptions import ObjectDoesNotExist +from django.db import transaction +from django.db.models import Q +from django.utils import dateformat, timezone +from django.utils.timezone import make_aware +from django.utils.translation import gettext_lazy as _ +from jwcrypto import jws, jwt +from jwcrypto.common import JWException +from jwcrypto.jwt import JWTExpired +from oauthlib.oauth2.rfc6749 import utils +from oauthlib.openid import RequestValidator +from tasks.tasks import update_user_apppermission + +from .exceptions import FatalClientError +from .models import ( + AbstractApplication, + get_access_token_model, + get_application_model, + get_grant_model, + get_id_token_model, + get_refresh_token_model, +) +from .scopes import get_scopes_backend +from .settings import oauth2_settings + +log = logging.getLogger("oauth2_provider") + +GRANT_TYPE_MAPPING = { + "authorization_code": ( + AbstractApplication.GRANT_AUTHORIZATION_CODE, + AbstractApplication.GRANT_OPENID_HYBRID, + ), + "password": (AbstractApplication.GRANT_PASSWORD,), + "client_credentials": (AbstractApplication.GRANT_CLIENT_CREDENTIALS,), + "refresh_token": ( + AbstractApplication.GRANT_AUTHORIZATION_CODE, + AbstractApplication.GRANT_PASSWORD, + AbstractApplication.GRANT_CLIENT_CREDENTIALS, + AbstractApplication.GRANT_OPENID_HYBRID, + ), +} + +Application = get_application_model() +AccessToken = get_access_token_model() +IDToken = get_id_token_model() +Grant = get_grant_model() +RefreshToken = get_refresh_token_model() +UserModel = get_user_model() + + +class OAuth2Validator(RequestValidator): + def _extract_basic_auth(self, request): + """ + Return authentication string if request contains basic auth credentials, + otherwise return None + """ + auth = request.headers.get("HTTP_AUTHORIZATION", None) + if not auth: + return None + + splitted = auth.split(" ", 1) + if len(splitted) != 2: + return None + auth_type, auth_string = splitted + + if auth_type != "Basic": + return None + + return auth_string + + def _authenticate_basic_auth(self, request): + """ + Authenticates with HTTP Basic Auth. + + Note: as stated in rfc:`2.3.1`, client_id and client_secret must be encoded with + "application/x-www-form-urlencoded" encoding algorithm. + """ + auth_string = self._extract_basic_auth(request) + if not auth_string: + return False + + try: + encoding = request.encoding or settings.DEFAULT_CHARSET or "utf-8" + except AttributeError: + encoding = "utf-8" + + try: + b64_decoded = base64.b64decode(auth_string) + except (TypeError, binascii.Error): + log.debug("Failed basic auth: %r can't be decoded as base64", auth_string) + return False + + try: + auth_string_decoded = b64_decoded.decode(encoding) + except UnicodeDecodeError: + log.debug("Failed basic auth: %r can't be decoded as unicode by %r", auth_string, encoding) + return False + + try: + client_id, client_secret = map(unquote_plus, auth_string_decoded.split(":", 1)) + except ValueError: + log.debug("Failed basic auth, Invalid base64 encoding.") + return False + + if self._load_application(client_id, request) is None: + log.debug("Failed basic auth: Application %s does not exist" % client_id) + return False + elif request.client.client_id != client_id: + log.debug("Failed basic auth: wrong client id %s" % client_id) + return False + elif request.client.client_secret != client_secret: + log.debug("Failed basic auth: wrong client secret %s" % client_secret) + return False + else: + return True + + def _authenticate_request_body(self, request): + """ + Try to authenticate the client using client_id and client_secret + parameters included in body. + + Remember that this method is NOT RECOMMENDED and SHOULD be limited to + clients unable to directly utilize the HTTP Basic authentication scheme. + See rfc:`2.3.1` for more details. + """ + # TODO: check if oauthlib has already unquoted client_id and client_secret + try: + client_id = request.client_id + client_secret = request.client_secret + except AttributeError: + return False + + if self._load_application(client_id, request) is None: + log.debug("Failed body auth: Application %s does not exists" % client_id) + return False + elif request.client.client_secret != client_secret: + log.debug("Failed body auth: wrong client secret %s" % client_secret) + return False + else: + return True + + def _load_application(self, client_id, request): + """ + If request.client was not set, load application instance for given + client_id and store it in request.client + """ + + # we want to be sure that request has the client attribute! + assert hasattr(request, "client"), '"request" instance has no "client" attribute' + + try: + request.client = request.client or Application.objects.get(client_id=client_id) + # Check that the application can be used (defaults to always True) + if not request.client.is_usable(request): + log.debug("Failed body authentication: Application %r is disabled" % (client_id)) + return None + return request.client + except Application.DoesNotExist: + log.debug("Failed body authentication: Application %r does not exist" % (client_id)) + return None + + def _set_oauth2_error_on_request(self, request, access_token, scopes): + if access_token is None: + error = OrderedDict( + [ + ("error", "invalid_token"), + ("error_description", _("The access token is invalid.")), + ] + ) + elif access_token.is_expired(): + error = OrderedDict( + [ + ("error", "invalid_token"), + ("error_description", _("The access token has expired.")), + ] + ) + elif not access_token.allow_scopes(scopes): + error = OrderedDict( + [ + ("error", "insufficient_scope"), + ("error_description", _("The access token is valid but does not have enough scope.")), + ] + ) + else: + log.warning("OAuth2 access token is invalid for an unknown reason.") + error = OrderedDict( + [ + ("error", "invalid_token"), + ] + ) + request.oauth2_error = error + return request + + def client_authentication_required(self, request, *args, **kwargs): + """ + Determine if the client has to be authenticated + + This method is called only for grant types that supports client authentication: + * Authorization code grant + * Resource owner password grant + * Refresh token grant + + If the request contains authorization headers, always authenticate the client + no matter the grant type. + + If the request does not contain authorization headers, proceed with authentication + only if the client is of type `Confidential`. + + If something goes wrong, call oauthlib implementation of the method. + """ + if self._extract_basic_auth(request): + return True + + try: + if request.client_id and request.client_secret: + return True + except AttributeError: + log.debug("Client ID or client secret not provided...") + pass + + self._load_application(request.client_id, request) + if request.client: + return request.client.client_type == AbstractApplication.CLIENT_CONFIDENTIAL + + return super().client_authentication_required(request, *args, **kwargs) + + def authenticate_client(self, request, *args, **kwargs): + """ + Check if client exists and is authenticating itself as in rfc:`3.2.1` + + First we try to authenticate with HTTP Basic Auth, and that is the PREFERRED + authentication method. + Whether this fails we support including the client credentials in the request-body, + but this method is NOT RECOMMENDED and SHOULD be limited to clients unable to + directly utilize the HTTP Basic authentication scheme. + See rfc:`2.3.1` for more details + """ + authenticated = self._authenticate_basic_auth(request) + + if not authenticated: + authenticated = self._authenticate_request_body(request) + + return authenticated + + def authenticate_client_id(self, client_id, request, *args, **kwargs): + """ + If we are here, the client did not authenticate itself as in rfc:`3.2.1` and we can + proceed only if the client exists and is not of type "Confidential". + """ + if self._load_application(client_id, request) is not None: + log.debug("Application %r has type %r" % (client_id, request.client.client_type)) + return request.client.client_type != AbstractApplication.CLIENT_CONFIDENTIAL + return False + + def confirm_redirect_uri(self, client_id, code, redirect_uri, client, *args, **kwargs): + """ + Ensure the redirect_uri is listed in the Application instance redirect_uris field + """ + grant = Grant.objects.get(code=code, application=client) + return grant.redirect_uri_allowed(redirect_uri) + + def invalidate_authorization_code(self, client_id, code, request, *args, **kwargs): + """ + Remove the temporary grant used to swap the authorization token + """ + grant = Grant.objects.get(code=code, application=request.client) + grant.delete() + + def validate_client_id(self, client_id, request, *args, **kwargs): + """ + Ensure an Application exists with given client_id. + If it exists, it's assigned to request.client. + """ + return self._load_application(client_id, request) is not None + + def get_default_redirect_uri(self, client_id, request, *args, **kwargs): + return request.client.default_redirect_uri + + def _get_token_from_authentication_server( + self, token, introspection_url, introspection_token, introspection_credentials + ): + """Use external introspection endpoint to "crack open" the token. + :param introspection_url: introspection endpoint URL + :param introspection_token: Bearer token + :param introspection_credentials: Basic Auth credentials (id,secret) + :return: :class:`models.AccessToken` + + Some RFC 7662 implementations (including this one) use a Bearer token while others use Basic + Auth. Depending on the external AS's implementation, provide either the introspection_token + or the introspection_credentials. + + If the resulting access_token identifies a username (e.g. Authorization Code grant), add + that user to the UserModel. Also cache the access_token up until its expiry time or a + configured maximum time. + + """ + headers = None + if introspection_token: + headers = {"Authorization": "Bearer {}".format(introspection_token)} + elif introspection_credentials: + client_id = introspection_credentials[0].encode("utf-8") + client_secret = introspection_credentials[1].encode("utf-8") + basic_auth = base64.b64encode(client_id + b":" + client_secret) + headers = {"Authorization": "Basic {}".format(basic_auth.decode("utf-8"))} + + try: + response = requests.post(introspection_url, data={"token": token}, headers=headers) + except requests.exceptions.RequestException: + log.exception("Introspection: Failed POST to %r in token lookup", introspection_url) + return None + + # Log an exception when response from auth server is not successful + if response.status_code != http.client.OK: + log.exception( + "Introspection: Failed to get a valid response " + "from authentication server. Status code: {}, " + "Reason: {}.".format(response.status_code, response.reason) + ) + return None + + try: + content = response.json() + except ValueError: + log.exception("Introspection: Failed to parse response as json") + return None + + if "active" in content and content["active"] is True: + if "username" in content: + user, _created = UserModel.objects.get_or_create( + **{UserModel.USERNAME_FIELD: content["username"]} + ) + else: + user = None + + max_caching_time = datetime.now() + timedelta( + seconds=oauth2_settings.RESOURCE_SERVER_TOKEN_CACHING_SECONDS + ) + + if "exp" in content: + expires = datetime.utcfromtimestamp(content["exp"]) + if expires > max_caching_time: + expires = max_caching_time + else: + expires = max_caching_time + + scope = content.get("scope", "") + expires = make_aware(expires) + + access_token, _created = AccessToken.objects.update_or_create( + token=token, + defaults={ + "user": user, + "application": None, + "scope": scope, + "expires": expires, + }, + ) + + return access_token + + def validate_bearer_token(self, token, scopes, request): + """ + When users try to access resources, check that provided token is valid + """ + if not token: + return False + + introspection_url = oauth2_settings.RESOURCE_SERVER_INTROSPECTION_URL + introspection_token = oauth2_settings.RESOURCE_SERVER_AUTH_TOKEN + introspection_credentials = oauth2_settings.RESOURCE_SERVER_INTROSPECTION_CREDENTIALS + + try: + access_token = AccessToken.objects.select_related("application", "user").get(token=token) + except AccessToken.DoesNotExist: + access_token = None + # if there is no token or it's invalid then introspect the token if there's an external OAuth server + if not access_token or not access_token.is_valid(scopes): + if introspection_url and (introspection_token or introspection_credentials): + access_token = self._get_token_from_authentication_server( + token, introspection_url, introspection_token, introspection_credentials + ) + + if access_token and access_token.is_valid(scopes): + request.client = access_token.application + request.user = access_token.user + request.user.tenant = access_token.tenant + request.scopes = scopes + + # this is needed by django rest framework + request.access_token = access_token + return True + else: + self._set_oauth2_error_on_request(request, access_token, scopes) + return False + + def validate_code(self, client_id, code, client, request, *args, **kwargs): + try: + grant = Grant.objects.get(code=code, application=client) + if not grant.is_expired(): + request.scopes = grant.scope.split(" ") + request.user = grant.user + request.user.tenant = grant.tenant + if grant.nonce: + request.nonce = grant.nonce + if grant.claims: + request.claims = json.loads(grant.claims) + return True + return False + + except Grant.DoesNotExist: + return False + + def validate_grant_type(self, client_id, grant_type, client, request, *args, **kwargs): + """ + Validate both grant_type is a valid string and grant_type is allowed for current workflow + """ + assert grant_type in GRANT_TYPE_MAPPING # mapping misconfiguration + return request.client.allows_grant_type(*GRANT_TYPE_MAPPING[grant_type]) + + def validate_response_type(self, client_id, response_type, client, request, *args, **kwargs): + """ + We currently do not support the Authorization Endpoint Response Types registry as in + rfc:`8.4`, so validate the response_type only if it matches "code" or "token" + """ + if response_type == "code": + return client.allows_grant_type(AbstractApplication.GRANT_AUTHORIZATION_CODE) + elif response_type == "token": + return client.allows_grant_type(AbstractApplication.GRANT_IMPLICIT) + elif response_type == "id_token": + return client.allows_grant_type(AbstractApplication.GRANT_IMPLICIT) + elif response_type == "id_token token": + return client.allows_grant_type(AbstractApplication.GRANT_IMPLICIT) + elif response_type == "code id_token": + return client.allows_grant_type(AbstractApplication.GRANT_OPENID_HYBRID) + elif response_type == "code token": + return client.allows_grant_type(AbstractApplication.GRANT_OPENID_HYBRID) + elif response_type == "code id_token token": + return client.allows_grant_type(AbstractApplication.GRANT_OPENID_HYBRID) + else: + return False + + def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs): + """ + Ensure required scopes are permitted (as specified in the settings file) + """ + available_scopes = get_scopes_backend().get_available_scopes(application=client, request=request) + return set(scopes).issubset(set(available_scopes)) + + def get_default_scopes(self, client_id, request, *args, **kwargs): + default_scopes = get_scopes_backend().get_default_scopes(application=request.client, request=request) + return default_scopes + + def validate_redirect_uri(self, client_id, redirect_uri, request, *args, **kwargs): + return request.client.redirect_uri_allowed(redirect_uri) + + def is_pkce_required(self, client_id, request): + """ + Enables or disables PKCE verification. + + Uses the setting PKCE_REQUIRED, which can be either a bool or a callable that + receives the client id and returns a bool. + """ + if callable(oauth2_settings.PKCE_REQUIRED): + return oauth2_settings.PKCE_REQUIRED(client_id) + return oauth2_settings.PKCE_REQUIRED + + def get_code_challenge(self, code, request): + grant = Grant.objects.get(code=code, application=request.client) + return grant.code_challenge or None + + def get_code_challenge_method(self, code, request): + grant = Grant.objects.get(code=code, application=request.client) + return grant.code_challenge_method or None + + def save_authorization_code(self, client_id, code, request, *args, **kwargs): + self._create_authorization_code(request, code) + + def get_authorization_code_scopes(self, client_id, code, redirect_uri, request): + scopes = Grant.objects.filter(code=code).values_list("scope", flat=True).first() + if scopes: + return utils.scope_to_list(scopes) + return [] + + def rotate_refresh_token(self, request): + """ + Checks if rotate refresh token is enabled + """ + return oauth2_settings.ROTATE_REFRESH_TOKEN + + @transaction.atomic + def save_bearer_token(self, token, request, *args, **kwargs): + """ + Save access and refresh token, If refresh token is issued, remove or + reuse old refresh token as in rfc:`6` + + @see: https://tools.ietf.org/html/draft-ietf-oauth-v2-31#page-43 + """ + + if "scope" not in token: + raise FatalClientError("Failed to renew access token: missing scope") + + # expires_in is passed to Server on initialization + # custom server class can have logic to override this + expires = timezone.now() + timedelta( + seconds=token.get( + "expires_in", + oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS, + ) + ) + + if request.grant_type == "client_credentials": + request.user = None + + # This comes from OAuthLib: + # https://github.com/idan/oauthlib/blob/1.0.3/oauthlib/oauth2/rfc6749/tokens.py#L267 + # Its value is either a new random code; or if we are reusing + # refresh tokens, then it is the same value that the request passed in + # (stored in `request.refresh_token`) + refresh_token_code = token.get("refresh_token", None) + + if refresh_token_code: + # an instance of `RefreshToken` that matches the old refresh code. + # Set on the request in `validate_refresh_token` + refresh_token_instance = getattr(request, "refresh_token_instance", None) + + # If we are to reuse tokens, and we can: do so + if ( + not self.rotate_refresh_token(request) + and isinstance(refresh_token_instance, RefreshToken) + and refresh_token_instance.access_token + ): + + access_token = AccessToken.objects.select_for_update().get( + pk=refresh_token_instance.access_token.pk + ) + access_token.user = request.user + access_token.scope = token["scope"] + access_token.expires = expires + access_token.token = token["access_token"] + access_token.application = request.client + access_token.save() + + # else create fresh with access & refresh tokens + else: + # revoke existing tokens if possible to allow reuse of grant + if isinstance(refresh_token_instance, RefreshToken): + # First, to ensure we don't have concurrency issues, we refresh the refresh token + # from the db while acquiring a lock on it + # We also put it in the "request cache" + refresh_token_instance = RefreshToken.objects.select_for_update().get( + id=refresh_token_instance.id + ) + request.refresh_token_instance = refresh_token_instance + + previous_access_token = AccessToken.objects.filter( + source_refresh_token=refresh_token_instance + ).first() + try: + refresh_token_instance.revoke() + except (AccessToken.DoesNotExist, RefreshToken.DoesNotExist): + pass + else: + setattr(request, "refresh_token_instance", None) + else: + previous_access_token = None + + # If the refresh token has already been used to create an + # access token (ie it's within the grace period), return that + # access token + if not previous_access_token: + access_token = self._create_access_token( + expires, + request, + token, + source_refresh_token=refresh_token_instance, + ) + + self._create_refresh_token(request, refresh_token_code, access_token) + else: + # make sure that the token data we're returning matches + # the existing token + token["access_token"] = previous_access_token.token + token["refresh_token"] = ( + RefreshToken.objects.filter(access_token=previous_access_token).first().token + ) + token["scope"] = previous_access_token.scope + + # No refresh token should be created, just access token + else: + self._create_access_token(expires, request, token) + + def _create_access_token(self, expires, request, token, source_refresh_token=None): + client = request.client + client_id = client.client_id + # 更新用户权限 + update_user_apppermission.delay(client_id, request.user.id) + # 颁发access_token + id_token = token.get("id_token", None) + if id_token: + id_token = IDToken.objects.get(token=id_token) + return AccessToken.objects.create( + user=request.user, + tenant=request.user.tenant, + scope=token["scope"], + expires=expires, + token=token["access_token"], + id_token=id_token, + application=request.client, + source_refresh_token=source_refresh_token, + ) + + def _create_authorization_code(self, request, code, expires=None): + if not expires: + expires = timezone.now() + timedelta(seconds=oauth2_settings.AUTHORIZATION_CODE_EXPIRE_SECONDS) + return Grant.objects.create( + application=request.client, + user=request.user, + tenant=request.user.tenant, + code=code["code"], + expires=expires, + redirect_uri=request.redirect_uri, + scope=" ".join(request.scopes), + code_challenge=request.code_challenge or "", + code_challenge_method=request.code_challenge_method or "", + nonce=request.nonce or "", + claims=json.dumps(request.claims or {}), + ) + + def _create_refresh_token(self, request, refresh_token_code, access_token): + return RefreshToken.objects.create( + user=request.user, token=refresh_token_code, application=request.client, access_token=access_token + ) + + def revoke_token(self, token, token_type_hint, request, *args, **kwargs): + """ + Revoke an access or refresh token. + + :param token: The token string. + :param token_type_hint: access_token or refresh_token. + :param request: The HTTP Request (oauthlib.common.Request) + """ + if token_type_hint not in ["access_token", "refresh_token"]: + token_type_hint = None + + token_types = { + "access_token": AccessToken, + "refresh_token": RefreshToken, + } + + token_type = token_types.get(token_type_hint, AccessToken) + try: + token_type.objects.get(token=token).revoke() + except ObjectDoesNotExist: + for other_type in [_t for _t in token_types.values() if _t != token_type]: + # slightly inefficient on Python2, but the queryset contains only one instance + list(map(lambda t: t.revoke(), other_type.objects.filter(token=token))) + + def validate_user(self, username, password, client, request, *args, **kwargs): + """ + Check username and password correspond to a valid and active User + """ + u = authenticate(username=username, password=password) + if u is not None and u.is_active: + request.user = u + return True + return False + + def get_original_scopes(self, refresh_token, request, *args, **kwargs): + # Avoid second query for RefreshToken since this method is invoked *after* + # validate_refresh_token. + rt = request.refresh_token_instance + if not rt.access_token_id: + return AccessToken.objects.get(source_refresh_token_id=rt.id).scope + + return rt.access_token.scope + + def validate_refresh_token(self, refresh_token, client, request, *args, **kwargs): + """ + Check refresh_token exists and refers to the right client. + Also attach User instance to the request object + """ + + null_or_recent = Q(revoked__isnull=True) | Q( + revoked__gt=timezone.now() - timedelta(seconds=oauth2_settings.REFRESH_TOKEN_GRACE_PERIOD_SECONDS) + ) + rt = ( + RefreshToken.objects.filter(null_or_recent, token=refresh_token) + .select_related("access_token") + .first() + ) + + if not rt: + return False + + request.user = rt.user + request.refresh_token = rt.token + # Temporary store RefreshToken instance to be reused by get_original_scopes and save_bearer_token. + request.refresh_token_instance = rt + return rt.application == client + + @transaction.atomic + def _save_id_token(self, token, request, expires, *args, **kwargs): + # TODO: http://openid.net/specs/openid-connect-core-1_0.html#HybridIDToken2 + # Save the id_token on database bound to code when the request come to + # Authorization Endpoint and return the same one when request come to + # Token Endpoint + + scopes = request.scope or " ".join(request.scopes) + + if request.grant_type == "client_credentials": + request.user = None + + id_token = IDToken.objects.create( + user=request.user, + tenant=request.user.tenant, + scope=scopes, + expires=expires, + token=token, + application=request.client, + ) + return id_token + + def get_jwt_bearer_token(self, token, token_handler, request): + return self.get_id_token(token, token_handler, request) + + def get_oidc_claims(self, token, token_handler, request): + # Required OIDC claims + claims = { + "sub": str(request.user.id), + } + + # https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims + claims.update(**self.get_additional_claims(request)) + + return claims + + def get_id_token_dictionary(self, token, token_handler, request): + """ + Get the claims to put in the ID Token. + + These claims are in addition to the claims automatically added by + ``oauthlib`` - aud, iat, nonce, at_hash, c_hash. + + This function adds in iss, exp and auth_time, plus any claims added from + calling ``get_oidc_claims()`` + """ + from config import get_app_config + claims = self.get_oidc_claims(token, token_handler, request) + expiration_time = timezone.now() + timedelta(seconds=oauth2_settings.ID_TOKEN_EXPIRE_SECONDS) + host = get_app_config().get_host() + urlinfo = request.uri + urlinfo = host+urlinfo + if urlinfo.find('/oauth/token/') != -1: + urlinfo = urlinfo.split('/oauth/token/')[0] + print('issinfo:'+urlinfo) + # Required ID Token claims + claims.update( + **{ + "iss": urlinfo, + "exp": int(dateformat.format(expiration_time, "U")), + "auth_time": int(dateformat.format(request.user.last_login, "U")), + } + ) + # claims.update( + # **{ + # "iss": self.get_oidc_issuer_endpoint(request), + # "exp": int(dateformat.format(expiration_time, "U")), + # "auth_time": int(dateformat.format(request.user.last_login, "U")), + # } + # ) + return claims, expiration_time + + def get_oidc_issuer_endpoint(self, request): + return oauth2_settings.oidc_issuer(request) + + def finalize_id_token(self, id_token, token, token_handler, request): + claims, expiration_time = self.get_id_token_dictionary(token, token_handler, request) + id_token.update(**claims) + # Workaround for oauthlib bug #746 + # https://github.com/oauthlib/oauthlib/issues/746 + if "nonce" not in id_token and request.nonce: + id_token["nonce"] = request.nonce + # 特殊处理添加认证模块(此处原来只有alg) + from jwcrypto import jwk + key = jwk.JWK.from_pem(oauth2_settings.OIDC_RSA_PRIVATE_KEY.encode("utf8")) + header = {"alg": "RS256", "use": "sig", "kid": key.thumbprint()} + header.update(json.loads(key.export_public())) + # 特殊处理添加认证模块结束 + jwt_token = jwt.JWT( + header=json.dumps(header, default=str), + claims=json.dumps(id_token, default=str), + ) + jwt_token.make_signed_token(request.client.jwk_key) + id_token_jwt = jwt_token.serialize() + id_token = self._save_id_token(id_token_jwt, request, expiration_time) + # this is needed by django rest framework + request.access_token = id_token + request.id_token = id_token + return id_token_jwt + + def validate_jwt_bearer_token(self, token, scopes, request): + return self.validate_id_token(token, scopes, request) + + def validate_id_token(self, token, scopes, request): + """ + When users try to access resources, check that provided id_token is valid + """ + if not token: + return False + + key = self._get_key_for_token(token) + if not key: + return False + try: + jwt_token = jwt.JWT(key=key, jwt=token) + id_token = IDToken.objects.get(token=jwt_token.serialize()) + except (JWException, JWTExpired): + return False + + request.client = id_token.application + request.user = id_token.user + request.scopes = scopes + # this is needed by django rest framework + request.access_token = id_token + return True + + def _get_key_for_token(self, token): + """ + Peek at the unvalidated token to discover who it was issued for + and then use that to load that application and its key. + """ + unverified_token = jws.JWS() + unverified_token.deserialize(token) + claims = json.loads(unverified_token.objects["payload"].decode("utf-8")) + if "aud" not in claims: + return None + application = self._get_client_by_audience(claims["aud"]) + if application: + return application.jwk_key + + def _get_client_by_audience(self, audience): + """ + Load a client by the aud claim in a JWT. + aud may be multi-valued, if your provider makes it so. + This function is separate to allow further customization. + """ + if isinstance(audience, str): + audience = [audience] + return Application.objects.filter(client_id__in=audience).first() + + def validate_user_match(self, id_token_hint, scopes, claims, request): + # TODO: Fix to validate when necessary acording + # https://github.com/idan/oauthlib/blob/master/oauthlib/oauth2/rfc6749/request_validator.py#L556 + # http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest id_token_hint section + return True + + def get_authorization_code_nonce(self, client_id, code, redirect_uri, request): + """Extracts nonce from saved authorization code. + If present in the Authentication Request, Authorization + Servers MUST include a nonce Claim in the ID Token with the + Claim Value being the nonce value sent in the Authentication + Request. Authorization Servers SHOULD perform no other + processing on nonce values used. The nonce value is a + case-sensitive string. + Only code param should be sufficient to retrieve grant code from + any storage you are using. However, `client_id` and `redirect_uri` + have been validated and can be used also. + :param client_id: Unicode client identifier + :param code: Unicode authorization code grant + :param redirect_uri: Unicode absolute URI + :return: Unicode nonce + Method is used by: + - Authorization Token Grant Dispatcher + """ + nonce = Grant.objects.filter(code=code).values_list("nonce", flat=True).first() + if nonce: + return nonce + + def get_userinfo_claims(self, request): + """ + Generates and saves a new JWT for this request, and returns it as the + current user's claims. + + """ + return self.get_oidc_claims(None, None, request) + + def get_additional_claims(self, request): + groups = [] + user = request.user + tenant = user.tenant + # for group in user.groups.all(): + # groups.append(group.name) + # if tenant.has_admin_perm(user) and 'tenant_admin' not in groups: + # groups.append('tenant_admin') + return { + "sub": str(request.user.id), + "sub_uuid": str(request.user.uuid), + "preferred_username": request.user.username, + 'nickname': request.user.nickname, + 'given_name': request.user.first_name, + 'family_name': request.user.last_name, + 'email': request.user.email, + 'groups': groups, + 'tenant_uuid': str(request.user.tenant.uuid), + "tenant_slug": request.user.tenant.slug, + } diff --git a/oauth2_provider/scopes.py b/oauth2_provider/scopes.py new file mode 100644 index 000000000..5fc1276ff --- /dev/null +++ b/oauth2_provider/scopes.py @@ -0,0 +1,50 @@ +from .settings import oauth2_settings + + +class BaseScopes: + def get_all_scopes(self): + """ + Return a dict-like object with all the scopes available in the + system. The key should be the scope name and the value should be + the description. + + ex: {"read": "A read scope", "write": "A write scope"} + """ + raise NotImplementedError("") + + def get_available_scopes(self, application=None, request=None, *args, **kwargs): + """ + Return a list of scopes available for the current application/request. + + TODO: add info on where and why this method is called. + + ex: ["read", "write"] + """ + raise NotImplementedError("") + + def get_default_scopes(self, application=None, request=None, *args, **kwargs): + """ + Return a list of the default scopes for the current application/request. + This MUST be a subset of the scopes returned by `get_available_scopes`. + + TODO: add info on where and why this method is called. + + ex: ["read"] + """ + raise NotImplementedError("") + + +class SettingsScopes(BaseScopes): + def get_all_scopes(self): + return oauth2_settings.SCOPES + + def get_available_scopes(self, application=None, request=None, *args, **kwargs): + return oauth2_settings._SCOPES + + def get_default_scopes(self, application=None, request=None, *args, **kwargs): + return oauth2_settings._DEFAULT_SCOPES + + +def get_scopes_backend(): + scopes_class = oauth2_settings.SCOPES_BACKEND_CLASS + return scopes_class() diff --git a/oauth2_provider/settings.py b/oauth2_provider/settings.py new file mode 100644 index 000000000..52b4f48de --- /dev/null +++ b/oauth2_provider/settings.py @@ -0,0 +1,303 @@ +""" +This module is largely inspired by django-rest-framework settings. + +Settings for the OAuth2 Provider are all namespaced in the OAUTH2_PROVIDER setting. +For example your project's `settings.py` file might look like this: + +OAUTH2_PROVIDER = { + "CLIENT_ID_GENERATOR_CLASS": + "oauth2_provider.generators.ClientIdGenerator", + "CLIENT_SECRET_GENERATOR_CLASS": + "oauth2_provider.generators.ClientSecretGenerator", +} + +This module provides the `oauth2_settings` object, that is used to access +OAuth2 Provider settings, checking for user settings first, then falling +back to the defaults. +""" + +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured +from django.http import HttpRequest +from django.test.signals import setting_changed +from django.urls import reverse +from django.utils.module_loading import import_string +from oauthlib.common import Request + + +USER_SETTINGS = getattr(settings, "OAUTH2_PROVIDER", None) + +APPLICATION_MODEL = getattr(settings, "OAUTH2_PROVIDER_APPLICATION_MODEL", "oauth2_provider.Application") +ACCESS_TOKEN_MODEL = getattr(settings, "OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL", "oauth2_provider.AccessToken") +ID_TOKEN_MODEL = getattr(settings, "OAUTH2_PROVIDER_ID_TOKEN_MODEL", "oauth2_provider.IDToken") +GRANT_MODEL = getattr(settings, "OAUTH2_PROVIDER_GRANT_MODEL", "oauth2_provider.Grant") +REFRESH_TOKEN_MODEL = getattr(settings, "OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL", "oauth2_provider.RefreshToken") + +DEFAULTS = { + "CLIENT_ID_GENERATOR_CLASS": "oauth2_provider.generators.ClientIdGenerator", + "CLIENT_SECRET_GENERATOR_CLASS": "oauth2_provider.generators.ClientSecretGenerator", + "CLIENT_SECRET_GENERATOR_LENGTH": 128, + "ACCESS_TOKEN_GENERATOR": None, + "REFRESH_TOKEN_GENERATOR": None, + "EXTRA_SERVER_KWARGS": {}, + "OAUTH2_SERVER_CLASS": "oauthlib.oauth2.Server", + "OIDC_SERVER_CLASS": "oauthlib.openid.Server", + "OAUTH2_VALIDATOR_CLASS": "oauth2_provider.oauth2_validators.OAuth2Validator", + "OAUTH2_BACKEND_CLASS": "oauth2_provider.oauth2_backends.OAuthLibCore", + "SCOPES": {"read": "Reading scope", "write": "Writing scope"}, + "DEFAULT_SCOPES": ["__all__"], + "SCOPES_BACKEND_CLASS": "oauth2_provider.scopes.SettingsScopes", + "READ_SCOPE": "read", + "WRITE_SCOPE": "write", + "AUTHORIZATION_CODE_EXPIRE_SECONDS": 60, + "ACCESS_TOKEN_EXPIRE_SECONDS": 36000, + "ID_TOKEN_EXPIRE_SECONDS": 36000, + "REFRESH_TOKEN_EXPIRE_SECONDS": None, + "REFRESH_TOKEN_GRACE_PERIOD_SECONDS": 0, + "ROTATE_REFRESH_TOKEN": True, + "ERROR_RESPONSE_WITH_SCOPES": False, + "APPLICATION_MODEL": APPLICATION_MODEL, + "ACCESS_TOKEN_MODEL": ACCESS_TOKEN_MODEL, + "ID_TOKEN_MODEL": ID_TOKEN_MODEL, + "GRANT_MODEL": GRANT_MODEL, + "REFRESH_TOKEN_MODEL": REFRESH_TOKEN_MODEL, + "APPLICATION_ADMIN_CLASS": "oauth2_provider.admin.ApplicationAdmin", + "ACCESS_TOKEN_ADMIN_CLASS": "oauth2_provider.admin.AccessTokenAdmin", + "GRANT_ADMIN_CLASS": "oauth2_provider.admin.GrantAdmin", + "ID_TOKEN_ADMIN_CLASS": "oauth2_provider.admin.IDTokenAdmin", + "REFRESH_TOKEN_ADMIN_CLASS": "oauth2_provider.admin.RefreshTokenAdmin", + "REQUEST_APPROVAL_PROMPT": "auto", + "ALLOWED_REDIRECT_URI_SCHEMES": ["http", "https"], + "OIDC_ENABLED": False, + "OIDC_ISS_ENDPOINT": "", + "OIDC_USERINFO_ENDPOINT": "", + "OIDC_RSA_PRIVATE_KEY": "", + "OIDC_RESPONSE_TYPES_SUPPORTED": [ + "code", + "token", + "id_token", + "id_token token", + "code token", + "code id_token", + "code id_token token", + ], + "OIDC_SUBJECT_TYPES_SUPPORTED": ["public"], + "OIDC_TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED": [ + "client_secret_post", + "client_secret_basic", + ], + # Special settings that will be evaluated at runtime + "_SCOPES": [], + "_DEFAULT_SCOPES": [], + # Resource Server with Token Introspection + "RESOURCE_SERVER_INTROSPECTION_URL": None, + "RESOURCE_SERVER_AUTH_TOKEN": None, + "RESOURCE_SERVER_INTROSPECTION_CREDENTIALS": None, + "RESOURCE_SERVER_TOKEN_CACHING_SECONDS": 36000, + # Whether or not PKCE is required + "PKCE_REQUIRED": False, + # Whether to re-create OAuthlibCore on every request. + # Should only be required in testing. + "ALWAYS_RELOAD_OAUTHLIB_CORE": False, +} + +# List of settings that cannot be empty +MANDATORY = ( + "CLIENT_ID_GENERATOR_CLASS", + "CLIENT_SECRET_GENERATOR_CLASS", + "OAUTH2_SERVER_CLASS", + "OAUTH2_VALIDATOR_CLASS", + "OAUTH2_BACKEND_CLASS", + "SCOPES", + "ALLOWED_REDIRECT_URI_SCHEMES", + "OIDC_RESPONSE_TYPES_SUPPORTED", + "OIDC_SUBJECT_TYPES_SUPPORTED", + "OIDC_TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED", +) + +# List of settings that may be in string import notation. +IMPORT_STRINGS = ( + "CLIENT_ID_GENERATOR_CLASS", + "CLIENT_SECRET_GENERATOR_CLASS", + "ACCESS_TOKEN_GENERATOR", + "REFRESH_TOKEN_GENERATOR", + "OAUTH2_SERVER_CLASS", + "OAUTH2_VALIDATOR_CLASS", + "OAUTH2_BACKEND_CLASS", + "SCOPES_BACKEND_CLASS", + "APPLICATION_ADMIN_CLASS", + "ACCESS_TOKEN_ADMIN_CLASS", + "GRANT_ADMIN_CLASS", + "ID_TOKEN_ADMIN_CLASS", + "REFRESH_TOKEN_ADMIN_CLASS", +) + + +def perform_import(val, setting_name): + """ + If the given setting is a string import notation, + then perform the necessary import or imports. + """ + if val is None: + return None + elif isinstance(val, str): + return import_from_string(val, setting_name) + elif isinstance(val, (list, tuple)): + return [import_from_string(item, setting_name) for item in val] + return val + + +def import_from_string(val, setting_name): + """ + Attempt to import a class from a string representation. + """ + try: + return import_string(val) + except ImportError as e: + msg = "Could not import %r for setting %r. %s: %s." % (val, setting_name, e.__class__.__name__, e) + raise ImportError(msg) + + +class OAuth2ProviderSettings: + """ + A settings object, that allows OAuth2 Provider settings to be accessed as properties. + + Any setting with string import paths will be automatically resolved + and return the class, rather than the string literal. + """ + + def __init__(self, user_settings=None, defaults=None, import_strings=None, mandatory=None): + self._user_settings = user_settings or {} + self.defaults = defaults or DEFAULTS + self.import_strings = import_strings or IMPORT_STRINGS + self.mandatory = mandatory or () + self._cached_attrs = set() + + @property + def user_settings(self): + if not hasattr(self, "_user_settings"): + self._user_settings = getattr(settings, "OAUTH2_PROVIDER", {}) + return self._user_settings + + def __getattr__(self, attr): + if attr not in self.defaults: + raise AttributeError("Invalid OAuth2Provider setting: %s" % attr) + try: + # Check if present in user settings + val = self.user_settings[attr] + except KeyError: + # Fall back to defaults + # Special case OAUTH2_SERVER_CLASS - if not specified, and OIDC is + # enabled, use the OIDC_SERVER_CLASS setting instead + if attr == "OAUTH2_SERVER_CLASS" and self.OIDC_ENABLED: + val = self.defaults["OIDC_SERVER_CLASS"] + else: + val = self.defaults[attr] + + # Coerce import strings into classes + if val and attr in self.import_strings: + val = perform_import(val, attr) + + # Overriding special settings + if attr == "_SCOPES": + val = list(self.SCOPES.keys()) + if attr == "_DEFAULT_SCOPES": + if "__all__" in self.DEFAULT_SCOPES: + # If DEFAULT_SCOPES is set to ["__all__"] the whole set of scopes is returned + val = list(self._SCOPES) + else: + # Otherwise we return a subset (that can be void) of SCOPES + val = [] + for scope in self.DEFAULT_SCOPES: + if scope in self._SCOPES: + val.append(scope) + else: + raise ImproperlyConfigured("Defined DEFAULT_SCOPES not present in SCOPES") + + self.validate_setting(attr, val) + + # Cache the result + self._cached_attrs.add(attr) + setattr(self, attr, val) + return val + + def validate_setting(self, attr, val): + if not val and attr in self.mandatory: + raise AttributeError("OAuth2Provider setting: %s is mandatory" % attr) + + @property + def server_kwargs(self): + """ + This is used to communicate settings to oauth server. + + Takes relevant settings and format them accordingly. + There's also EXTRA_SERVER_KWARGS that can override every value + and is more flexible regarding keys and acceptable values + but doesn't have import string magic or any additional + processing, callables have to be assigned directly. + For the likes of signed_token_generator it means something like + + {"token_generator": signed_token_generator(privkey, **kwargs)} + """ + kwargs = { + key: getattr(self, value) + for key, value in [ + ("token_expires_in", "ACCESS_TOKEN_EXPIRE_SECONDS"), + ("refresh_token_expires_in", "REFRESH_TOKEN_EXPIRE_SECONDS"), + ("token_generator", "ACCESS_TOKEN_GENERATOR"), + ("refresh_token_generator", "REFRESH_TOKEN_GENERATOR"), + ] + } + kwargs.update(self.EXTRA_SERVER_KWARGS) + return kwargs + + def reload(self): + for attr in self._cached_attrs: + delattr(self, attr) + self._cached_attrs.clear() + if hasattr(self, "_user_settings"): + delattr(self, "_user_settings") + + def oidc_issuer(self, request, tenant=''): + from config import get_app_config + host = get_app_config().get_host() + """ + Helper function to get the OIDC issuer URL, either from the settings + or constructing it from the passed request. + + If only an oauthlib request is available, a dummy django request is + built from that and used to generate the URL. + """ + # code=NSi6ZGPOusmyqvwlXko70kbewDMcol&grant_type=authorization_code&tenant_uuid=3efed4d9-f2ee-455e-b868-6f60ea8fdff6 + body = request.body + if body: + arrs = body.split('&') + for item in arrs: + if 'tenant_uuid=' in item: + tenant = item[12:] + if self.OIDC_ISS_ENDPOINT: + return self.OIDC_ISS_ENDPOINT + if isinstance(request, HttpRequest): + django_request = request + elif isinstance(request, Request): + django_request = HttpRequest() + django_request.META = request.headers + else: + raise TypeError("request must be a django or oauthlib request: got %r" % request) + if tenant: + abs_url = host+reverse("api:oauth2_authorization_server:oidc-connect-discovery-info", args=[tenant]) + else: + abs_url = '' + return abs_url[: -len("/.well-known/openid-configuration/")] + + +oauth2_settings = OAuth2ProviderSettings(USER_SETTINGS, DEFAULTS, IMPORT_STRINGS, MANDATORY) + + +def reload_oauth2_settings(*args, **kwargs): + setting = kwargs["setting"] + if setting == "OAUTH2_PROVIDER": + oauth2_settings.reload() + + +setting_changed.connect(reload_oauth2_settings) diff --git a/oauth2_provider/signals.py b/oauth2_provider/signals.py new file mode 100644 index 000000000..1640bda03 --- /dev/null +++ b/oauth2_provider/signals.py @@ -0,0 +1,4 @@ +from django.dispatch import Signal + + +app_authorized = Signal() # providing_args=["request", "token"] diff --git a/oauth2_provider/templates/oauth2_provider/application_confirm_delete.html b/oauth2_provider/templates/oauth2_provider/application_confirm_delete.html new file mode 100644 index 000000000..ab497067d --- /dev/null +++ b/oauth2_provider/templates/oauth2_provider/application_confirm_delete.html @@ -0,0 +1,18 @@ +{% extends "oauth2_provider/base.html" %} + +{% load i18n %} +{% block content %} +
+

{% trans "Are you sure to delete the application" %} {{ application.name }}?

+
+ {% csrf_token %} + + +
+
+{% endblock content %} diff --git a/oauth2_provider/templates/oauth2_provider/application_detail.html b/oauth2_provider/templates/oauth2_provider/application_detail.html new file mode 100644 index 000000000..244041be3 --- /dev/null +++ b/oauth2_provider/templates/oauth2_provider/application_detail.html @@ -0,0 +1,41 @@ +{% extends "oauth2_provider/base.html" %} + +{% load i18n %} +{% block content %} +
+

{{ application.name }}

+ +
    +
  • +

    {% trans "Client id" %}

    + +
  • + +
  • +

    {% trans "Client secret" %}

    + +
  • + +
  • +

    {% trans "Client type" %}

    +

    {{ application.client_type }}

    +
  • + +
  • +

    {% trans "Authorization Grant Type" %}

    +

    {{ application.authorization_grant_type }}

    +
  • + +
  • +

    {% trans "Redirect Uris" %}

    + +
  • +
+ + +
+{% endblock content %} diff --git a/oauth2_provider/templates/oauth2_provider/application_form.html b/oauth2_provider/templates/oauth2_provider/application_form.html new file mode 100644 index 000000000..d67a4413a --- /dev/null +++ b/oauth2_provider/templates/oauth2_provider/application_form.html @@ -0,0 +1,42 @@ +{% extends "oauth2_provider/base.html" %} + +{% load i18n %} +{% block content %} +
+
+

+ {% block app-form-title %} + {% trans "Edit application" %} {{ application.name }} + {% endblock app-form-title %} +

+ {% csrf_token %} + + {% for field in form %} +
+ +
+ {{ field }} + {% for error in field.errors %} + {{ error }} + {% endfor %} +
+
+ {% endfor %} + +
+ {% for error in form.non_field_errors %} + {{ error }} + {% endfor %} +
+ +
+
+ + {% trans "Go Back" %} + + +
+
+
+
+{% endblock %} diff --git a/oauth2_provider/templates/oauth2_provider/application_list.html b/oauth2_provider/templates/oauth2_provider/application_list.html new file mode 100644 index 000000000..4d4ae5151 --- /dev/null +++ b/oauth2_provider/templates/oauth2_provider/application_list.html @@ -0,0 +1,20 @@ +{% extends "oauth2_provider/base.html" %} + +{% load i18n %} +{% block content %} +
+

{% trans "Your applications" %}

+ {% if applications %} + + + {% trans "New Application" %} + {% else %} + +

{% trans "No applications defined" %}. {% trans "Click here" %} {% trans "if you want to register a new one" %}

+ {% endif %} +
+{% endblock content %} diff --git a/oauth2_provider/templates/oauth2_provider/application_registration_form.html b/oauth2_provider/templates/oauth2_provider/application_registration_form.html new file mode 100644 index 000000000..98499dff5 --- /dev/null +++ b/oauth2_provider/templates/oauth2_provider/application_registration_form.html @@ -0,0 +1,9 @@ +{% extends "oauth2_provider/application_form.html" %} + +{% load i18n %} + +{% block app-form-title %}{% trans "Register a new application" %}{% endblock app-form-title %} + +{% block app-form-action-url %}{% url 'api:register' %}{% endblock app-form-action-url %} + +{% block app-form-back-url %}{% url "api:list" %}"{% endblock app-form-back-url %} diff --git a/oauth2_provider/templates/oauth2_provider/authorize.html b/oauth2_provider/templates/oauth2_provider/authorize.html new file mode 100644 index 000000000..b75efb96d --- /dev/null +++ b/oauth2_provider/templates/oauth2_provider/authorize.html @@ -0,0 +1,40 @@ +{% extends "oauth2_provider/base.html" %} + +{% load i18n %} +{% block content %} +
+ {% if not error %} +
+

{% trans "Authorize" %} {{ application.name }}?

+ {% csrf_token %} + + {% for field in form %} + {% if field.is_hidden %} + {{ field }} + {% endif %} + {% endfor %} + +

{% trans "Application requires following permissions" %}

+
    + {% for scope in scopes_descriptions %} +
  • {{ scope }}
  • + {% endfor %} +
+ + {{ form.errors }} + {{ form.non_field_errors }} + +
+
+ + +
+
+
+ + {% else %} +

Error: {{ error.error }}

+

{{ error.description }}

+ {% endif %} +
+{% endblock %} \ No newline at end of file diff --git a/oauth2_provider/templates/oauth2_provider/authorized-oob.html b/oauth2_provider/templates/oauth2_provider/authorized-oob.html new file mode 100644 index 000000000..78399da7c --- /dev/null +++ b/oauth2_provider/templates/oauth2_provider/authorized-oob.html @@ -0,0 +1,23 @@ +{% extends "oauth2_provider/base.html" %} + +{% load i18n %} + +{% block title %} +Success code={{code}} +{% endblock %} + +{% block content %} +
+ {% if not error %} +

{% trans "Success" %}

+ +

{% trans "Please return to your application and enter this code:" %}

+ +

{{ code }}

+ + {% else %} +

Error: {{ error.error }}

+

{{ error.description }}

+ {% endif %} +
+{% endblock %} diff --git a/oauth2_provider/templates/oauth2_provider/authorized-token-delete.html b/oauth2_provider/templates/oauth2_provider/authorized-token-delete.html new file mode 100644 index 000000000..02a6ff402 --- /dev/null +++ b/oauth2_provider/templates/oauth2_provider/authorized-token-delete.html @@ -0,0 +1,9 @@ +{% extends "oauth2_provider/base.html" %} + +{% load i18n %} +{% block content %} +
{% csrf_token %} +

{% trans "Are you sure you want to delete this token?" %}

+ +
+{% endblock %} diff --git a/oauth2_provider/templates/oauth2_provider/authorized-tokens.html b/oauth2_provider/templates/oauth2_provider/authorized-tokens.html new file mode 100644 index 000000000..aa79e395f --- /dev/null +++ b/oauth2_provider/templates/oauth2_provider/authorized-tokens.html @@ -0,0 +1,23 @@ +{% extends "oauth2_provider/base.html" %} + +{% load i18n %} +{% block content %} +
+

{% trans "Tokens" %}

+
    + {% for authorized_token in authorized_tokens %} +
  • + {{ authorized_token.application }} + ({% trans "revoke" %}) +
  • +
      + {% for scope_name, scope_description in authorized_token.scopes.items %} +
    • {{ scope_name }}: {{ scope_description }}
    • + {% endfor %} +
    + {% empty %} +
  • {% trans "There are no authorized tokens yet." %}
  • + {% endfor %} +
+
+{% endblock %} diff --git a/oauth2_provider/templates/oauth2_provider/base.html b/oauth2_provider/templates/oauth2_provider/base.html new file mode 100644 index 000000000..048c41f46 --- /dev/null +++ b/oauth2_provider/templates/oauth2_provider/base.html @@ -0,0 +1,48 @@ + + + + + {% block title %}{% endblock title %} + + + + + {% block css %} + + {% endblock css %} + + + + + + +
+ {% block content %} + {% endblock content %} +
+ + + diff --git a/oauth2_provider/urls.py b/oauth2_provider/urls.py new file mode 100644 index 000000000..400a6069c --- /dev/null +++ b/oauth2_provider/urls.py @@ -0,0 +1,49 @@ +from django.urls import re_path + +from . import views + + +app_name = "oauth2_provider" + + +base_urlpatterns = [ + re_path(r"^authorize/$", views.AuthorizationView.as_view(), name="authorize"), + re_path(r"^token/$", views.TokenView.as_view(), name="token"), + re_path(r"^revoke_token/$", views.RevokeTokenView.as_view(), name="revoke-token"), + re_path(r"^introspect/$", views.IntrospectTokenView.as_view(), name="introspect"), +] + + +management_urlpatterns = [ + # Application management views + re_path(r"^applications/$", views.ApplicationList.as_view(), name="list"), + re_path(r"^applications/register/$", views.ApplicationRegistration.as_view(), name="register"), + re_path(r"^applications/(?P[\w-]+)/$", views.ApplicationDetail.as_view(), name="detail"), + re_path(r"^applications/(?P[\w-]+)/delete/$", views.ApplicationDelete.as_view(), name="delete"), + re_path(r"^applications/(?P[\w-]+)/update/$", views.ApplicationUpdate.as_view(), name="update"), + # Token management views + re_path(r"^authorized_tokens/$", views.AuthorizedTokensListView.as_view(), name="authorized-token-list"), + re_path( + r"^authorized_tokens/(?P[\w-]+)/delete/$", + views.AuthorizedTokenDeleteView.as_view(), + name="authorized-token-delete", + ), +] + +oidc_urlpatterns = [ + re_path( + r"^\.well-known/openid-configuration/$", + views.ConnectDiscoveryInfoView.as_view(), + name="oidc-connect-discovery-info", + ), + # re_path( + # r'^tenant/(?P[\w-]+)/\.well-known/openid-configuration/$', + # views.ConnectDiscoveryInfoView.as_view(), + # name='oidc-connect-discovery-info', + # ), + re_path(r"^\.well-known/jwks.json$", views.JwksInfoView.as_view(), name="jwks-info"), + re_path(r"^userinfo/$", views.UserInfoView.as_view(), name="user-info"), +] + + +urlpatterns = base_urlpatterns + management_urlpatterns + oidc_urlpatterns diff --git a/oauth2_provider/validators.py b/oauth2_provider/validators.py new file mode 100644 index 000000000..6c8fa3839 --- /dev/null +++ b/oauth2_provider/validators.py @@ -0,0 +1,46 @@ +import re +from urllib.parse import urlsplit + +from django.core.exceptions import ValidationError +from django.core.validators import URLValidator +from django.utils.encoding import force_str + + +class URIValidator(URLValidator): + scheme_re = r"^(?:[a-z][a-z0-9\.\-\+]*)://" + + dotless_domain_re = r"(?!-)[A-Z\d-]{1,63}(? assume an in-house applications + # are already approved. + if application.skip_authorization: + uri, headers, body, status = self.create_authorization_response( + request=self.request, scopes=" ".join(scopes), credentials=credentials, allow=True + ) + return self.redirect(uri, application) + + elif require_approval == "auto": + tokens = ( + get_access_token_model() + .objects.filter( + user=request.user, application=kwargs["application"], expires__gt=timezone.now() + ) + .all() + ) + + # check past authorizations regarded the same scopes as the current one + for token in tokens: + if token.allow_scopes(scopes): + uri, headers, body, status = self.create_authorization_response( + request=self.request, + scopes=" ".join(scopes), + credentials=credentials, + allow=True, + ) + return self.redirect(uri, application, token) + + except OAuthToolkitError as error: + return self.error_response(error, application) + + if not application.custom_template: + return self.render_to_response(self.get_context_data(**kwargs)) + else: + credentials.pop('request') # object can not json serialize + request.session['credentials'] = credentials + request.session['scopes'] = scopes + + template = Template(application.custom_template) + rendered_template = template.render(RequestContext(request)) + return HttpResponse(rendered_template) + + # return TemplateResponse(request, template, {}) + + # response = HttpResponse(application.custom_template) + # return response + + def redirect(self, redirect_to, application, token=None): + + if not redirect_to.startswith("urn:ietf:wg:oauth:2.0:oob"): + return super().redirect(redirect_to, application) + + parsed_redirect = urllib.parse.urlparse(redirect_to) + code = urllib.parse.parse_qs(parsed_redirect.query)["code"][0] + + if redirect_to.startswith("urn:ietf:wg:oauth:2.0:oob:auto"): + + response = { + "access_token": code, + "token_uri": redirect_to, + "client_id": application.client_id, + "client_secret": application.client_secret, + "revoke_uri": reverse("api:revoke-token"), + } + + return JsonResponse(response) + + else: + return render( + request=self.request, + template_name="oauth2_provider/authorized-oob.html", + context={ + "code": code, + }, + ) + + +@method_decorator(csrf_exempt, name="dispatch") +class TokenView(OAuthLibMixin, View): + """ + Implements an endpoint to provide access tokens + + The endpoint is used in the following flows: + * Authorization code + * Password + * Client credentials + """ + + @method_decorator(sensitive_post_parameters("password")) + def post(self, request, *args, **kwargs): + tenant_uuid = kwargs.get('tenant_uuid') + if tenant_uuid: + tenant = Tenant.objects.get(uuid=tenant_uuid) + else: + tenant = None + + url, headers, body, status = self.create_token_response(request, tenant) + if status == 200: + access_token = json.loads(body).get("access_token") + if access_token is not None: + token = get_access_token_model().objects.get(token=access_token) + app_authorized.send(sender=self, request=request, token=token) + response = HttpResponse(content=body, status=status) + + for k, v in headers.items(): + response[k] = v + return response + + +@method_decorator(csrf_exempt, name="dispatch") +class RevokeTokenView(OAuthLibMixin, View): + """ + Implements an endpoint to revoke access or refresh tokens + """ + + def post(self, request, *args, **kwargs): + url, headers, body, status = self.create_revocation_response(request) + response = HttpResponse(content=body or "", status=status) + + for k, v in headers.items(): + response[k] = v + return response diff --git a/oauth2_provider/views/generic.py b/oauth2_provider/views/generic.py new file mode 100644 index 000000000..da675eac4 --- /dev/null +++ b/oauth2_provider/views/generic.py @@ -0,0 +1,51 @@ +from django.views.generic import View + +from .mixins import ( + ClientProtectedResourceMixin, + OAuthLibMixin, + ProtectedResourceMixin, + ReadWriteScopedResourceMixin, + ScopedResourceMixin, +) + + +class ProtectedResourceView(ProtectedResourceMixin, OAuthLibMixin, View): + """ + Generic view protecting resources by providing OAuth2 authentication out of the box + """ + + pass + + +class ScopedProtectedResourceView(ScopedResourceMixin, ProtectedResourceView): + """ + Generic view protecting resources by providing OAuth2 authentication and Scopes handling + out of the box + """ + + pass + + +class ReadWriteScopedResourceView(ReadWriteScopedResourceMixin, ProtectedResourceView): + """ + Generic view protecting resources with OAuth2 authentication and read/write scopes. + GET, HEAD, OPTIONS http methods require "read" scope. Otherwise "write" scope is required. + """ + + pass + + +class ClientProtectedResourceView(ClientProtectedResourceMixin, OAuthLibMixin, View): + + """View for protecting a resource with client-credentials method. + This involves allowing access tokens, Basic Auth and plain credentials in request body. + """ + + pass + + +class ClientProtectedScopedResourceView(ScopedResourceMixin, ClientProtectedResourceView): + + """Impose scope restrictions if client protection fallsback to access token.""" + + pass diff --git a/oauth2_provider/views/introspect.py b/oauth2_provider/views/introspect.py new file mode 100644 index 000000000..afb8ac627 --- /dev/null +++ b/oauth2_provider/views/introspect.py @@ -0,0 +1,80 @@ +import calendar +import json + +from django.core.exceptions import ObjectDoesNotExist +from django.http import HttpResponse +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import csrf_exempt + +from oauth2_provider.models import get_access_token_model +from oauth2_provider.views.generic import ClientProtectedScopedResourceView + + +@method_decorator(csrf_exempt, name="dispatch") +class IntrospectTokenView(ClientProtectedScopedResourceView): + """ + Implements an endpoint for token introspection based + on RFC 7662 https://tools.ietf.org/html/rfc7662 + + To access this view the request must pass a OAuth2 Bearer Token + which is allowed to access the scope `introspection`. + """ + + required_scopes = ["introspection"] + + @staticmethod + def get_token_response(token_value=None): + try: + token = ( + get_access_token_model().objects.select_related("user", "application").get(token=token_value) + ) + except ObjectDoesNotExist: + return HttpResponse( + content=json.dumps({"active": False}), status=401, content_type="application/json" + ) + else: + if token.is_valid(): + data = { + "active": True, + "scope": token.scope, + "exp": int(calendar.timegm(token.expires.timetuple())), + } + if token.application: + data["client_id"] = token.application.client_id + if token.user: + data["username"] = token.user.get_username() + return HttpResponse(content=json.dumps(data), status=200, content_type="application/json") + else: + return HttpResponse( + content=json.dumps( + { + "active": False, + } + ), + status=200, + content_type="application/json", + ) + + def get(self, request, *args, **kwargs): + """ + Get the token from the URL parameters. + URL: https://example.com/introspect?token=mF_9.B5f-4.1JqM + + :param request: + :param args: + :param kwargs: + :return: + """ + return self.get_token_response(request.GET.get("token", None)) + + def post(self, request, *args, **kwargs): + """ + Get the token from the body form parameters. + Body: token=mF_9.B5f-4.1JqM + + :param request: + :param args: + :param kwargs: + :return: + """ + return self.get_token_response(request.POST.get("token", None)) diff --git a/oauth2_provider/views/mixins.py b/oauth2_provider/views/mixins.py new file mode 100644 index 000000000..880be11da --- /dev/null +++ b/oauth2_provider/views/mixins.py @@ -0,0 +1,324 @@ +import logging + +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured +from django.http import HttpResponseForbidden, HttpResponseNotFound + +from ..exceptions import FatalClientError +from ..scopes import get_scopes_backend +from ..settings import oauth2_settings + + +log = logging.getLogger("oauth2_provider") + +SAFE_HTTP_METHODS = ["GET", "HEAD", "OPTIONS"] + + +class OAuthLibMixin: + """ + This mixin decouples Django OAuth Toolkit from OAuthLib. + + Users can configure the Server, Validator and OAuthlibCore + classes used by this mixin by setting the following class + variables: + + * server_class + * validator_class + * oauthlib_backend_class + + If these class variables are not set, it will fall back to using the classes + specified in oauth2_settings (OAUTH2_SERVER_CLASS, OAUTH2_VALIDATOR_CLASS + and OAUTH2_BACKEND_CLASS). + """ + + server_class = None + validator_class = None + oauthlib_backend_class = None + + @classmethod + def get_server_class(cls): + """ + Return the OAuthlib server class to use + """ + if cls.server_class is None: + return oauth2_settings.OAUTH2_SERVER_CLASS + else: + return cls.server_class + + @classmethod + def get_validator_class(cls): + """ + Return the RequestValidator implementation class to use + """ + if cls.validator_class is None: + return oauth2_settings.OAUTH2_VALIDATOR_CLASS + else: + return cls.validator_class + + @classmethod + def get_oauthlib_backend_class(cls): + """ + Return the OAuthLibCore implementation class to use + """ + if cls.oauthlib_backend_class is None: + return oauth2_settings.OAUTH2_BACKEND_CLASS + else: + return cls.oauthlib_backend_class + + @classmethod + def get_server(cls): + """ + Return an instance of `server_class` initialized with a `validator_class` + object + """ + server_class = cls.get_server_class() + validator_class = cls.get_validator_class() + server_kwargs = oauth2_settings.server_kwargs + return server_class(validator_class(), **server_kwargs) + + @classmethod + def get_oauthlib_core(cls): + """ + Cache and return `OAuthlibCore` instance so it will be created only on first request + unless ALWAYS_RELOAD_OAUTHLIB_CORE is True. + """ + if not hasattr(cls, "_oauthlib_core") or oauth2_settings.ALWAYS_RELOAD_OAUTHLIB_CORE: + server = cls.get_server() + core_class = cls.get_oauthlib_backend_class() + cls._oauthlib_core = core_class(server) + return cls._oauthlib_core + + def validate_authorization_request(self, request): + """ + A wrapper method that calls validate_authorization_request on `server_class` instance. + + :param request: The current django.http.HttpRequest object + """ + core = self.get_oauthlib_core() + return core.validate_authorization_request(request) + + def create_authorization_response(self, request, scopes, credentials, allow): + """ + A wrapper method that calls create_authorization_response on `server_class` + instance. + + :param request: The current django.http.HttpRequest object + :param scopes: A space-separated string of provided scopes + :param credentials: Authorization credentials dictionary containing + `client_id`, `state`, `redirect_uri` and `response_type` + :param allow: True if the user authorize the client, otherwise False + """ + # TODO: move this scopes conversion from and to string into a utils function + scopes = scopes.split(" ") if scopes else [] + + core = self.get_oauthlib_core() + return core.create_authorization_response(request, scopes, credentials, allow) + + def create_token_response(self, request, tenant): + """ + A wrapper method that calls create_token_response on `server_class` instance. + + :param request: The current django.http.HttpRequest object + """ + core = self.get_oauthlib_core() + return core.create_token_response(request, tenant) + + def create_revocation_response(self, request): + """ + A wrapper method that calls create_revocation_response on the + `server_class` instance. + + :param request: The current django.http.HttpRequest object + """ + core = self.get_oauthlib_core() + return core.create_revocation_response(request) + + def create_userinfo_response(self, request): + """ + A wrapper method that calls create_userinfo_response on the + `server_class` instance. + + :param request: The current django.http.HttpRequest object + """ + core = self.get_oauthlib_core() + return core.create_userinfo_response(request) + + def verify_request(self, request): + """ + A wrapper method that calls verify_request on `server_class` instance. + + :param request: The current django.http.HttpRequest object + """ + core = self.get_oauthlib_core() + return core.verify_request(request, scopes=self.get_scopes()) + + def get_scopes(self): + """ + This should return the list of scopes required to access the resources. + By default it returns an empty list. + """ + return [] + + def error_response(self, error, **kwargs): + """ + Return an error to be displayed to the resource owner if anything goes awry. + + :param error: :attr:`OAuthToolkitError` + """ + oauthlib_error = error.oauthlib_error + + redirect_uri = oauthlib_error.redirect_uri or "" + separator = "&" if "?" in redirect_uri else "?" + + error_response = { + "error": oauthlib_error, + "url": redirect_uri + separator + oauthlib_error.urlencoded, + } + error_response.update(kwargs) + + # If we got a malicious redirect_uri or client_id, we will *not* redirect back to the URL. + if isinstance(error, FatalClientError): + redirect = False + else: + redirect = True + + return redirect, error_response + + def authenticate_client(self, request): + """Returns a boolean representing if client is authenticated with client credentials + method. Returns `True` if authenticated. + + :param request: The current django.http.HttpRequest object + """ + core = self.get_oauthlib_core() + return core.authenticate_client(request) + + +class ScopedResourceMixin: + """ + Helper mixin that implements "scopes handling" behaviour + """ + + required_scopes = None + + def get_scopes(self, *args, **kwargs): + """ + Return the scopes needed to access the resource + + :param args: Support scopes injections from the outside (not yet implemented) + """ + if self.required_scopes is None: + raise ImproperlyConfigured( + "ProtectedResourceMixin requires either a definition of 'required_scopes'" + " or an implementation of 'get_scopes()'" + ) + else: + return self.required_scopes + + +class ProtectedResourceMixin(OAuthLibMixin): + """ + Helper mixin that implements OAuth2 protection on request dispatch, + specially useful for Django Generic Views + """ + + def dispatch(self, request, *args, **kwargs): + # let preflight OPTIONS requests pass + if request.method.upper() == "OPTIONS": + return super().dispatch(request, *args, **kwargs) + + # check if the request is valid and the protected resource may be accessed + valid, r = self.verify_request(request) + if valid: + request.resource_owner = r.user + return super().dispatch(request, *args, **kwargs) + else: + return HttpResponseForbidden() + + +class ReadWriteScopedResourceMixin(ScopedResourceMixin, OAuthLibMixin): + """ + Helper mixin that implements "read and write scopes" behavior + """ + + required_scopes = [] + read_write_scope = None + + def __new__(cls, *args, **kwargs): + provided_scopes = get_scopes_backend().get_all_scopes() + read_write_scopes = [oauth2_settings.READ_SCOPE, oauth2_settings.WRITE_SCOPE] + + if not set(read_write_scopes).issubset(set(provided_scopes)): + raise ImproperlyConfigured( + "ReadWriteScopedResourceMixin requires following scopes {}" + ' to be in OAUTH2_PROVIDER["SCOPES"] list in settings'.format(read_write_scopes) + ) + + return super().__new__(cls, *args, **kwargs) + + def dispatch(self, request, *args, **kwargs): + if request.method.upper() in SAFE_HTTP_METHODS: + self.read_write_scope = oauth2_settings.READ_SCOPE + else: + self.read_write_scope = oauth2_settings.WRITE_SCOPE + + return super().dispatch(request, *args, **kwargs) + + def get_scopes(self, *args, **kwargs): + scopes = super().get_scopes(*args, **kwargs) + + # this returns a copy so that self.required_scopes is not modified + return scopes + [self.read_write_scope] + + +class ClientProtectedResourceMixin(OAuthLibMixin): + + """Mixin for protecting resources with client authentication as mentioned in rfc:`3.2.1` + This involves authenticating with any of: HTTP Basic Auth, Client Credentials and + Access token in that order. Breaks off after first validation. + """ + + def dispatch(self, request, *args, **kwargs): + # let preflight OPTIONS requests pass + if request.method.upper() == "OPTIONS": + return super().dispatch(request, *args, **kwargs) + # Validate either with HTTP basic or client creds in request body. + # TODO: Restrict to POST. + valid = self.authenticate_client(request) + if not valid: + # Alternatively allow access tokens + # check if the request is valid and the protected resource may be accessed + try: + valid, r = self.verify_request(request) + if valid: + request.resource_owner = r.user + return super().dispatch(request, *args, **kwargs) + except ValueError: + pass + return HttpResponseForbidden() + else: + return super().dispatch(request, *args, **kwargs) + + +class OIDCOnlyMixin: + """ + Mixin for views that should only be accessible when OIDC is enabled. + + If OIDC is not enabled: + + * if DEBUG is True, raises an ImproperlyConfigured exception explaining why + * otherwise, returns a 404 response, logging the same warning + """ + + debug_error_message = ( + "django-oauth-toolkit OIDC views are not enabled unless you " + "have configured OIDC_ENABLED in the settings" + ) + + def dispatch(self, *args, **kwargs): + if not oauth2_settings.OIDC_ENABLED: + if settings.DEBUG: + raise ImproperlyConfigured(self.debug_error_message) + log.warning(self.debug_error_message) + return HttpResponseNotFound() + return super().dispatch(*args, **kwargs) diff --git a/oauth2_provider/views/oidc.py b/oauth2_provider/views/oidc.py new file mode 100644 index 000000000..e789515d5 --- /dev/null +++ b/oauth2_provider/views/oidc.py @@ -0,0 +1,241 @@ +import json +import re +from django.http import HttpResponse, JsonResponse, HttpResponseRedirect +from django.urls import reverse +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import csrf_exempt +from django.views.generic import View +from jwcrypto import jwk + +from ..models import get_application_model +from ..settings import oauth2_settings +from .mixins import OAuthLibMixin, OIDCOnlyMixin +from oauth2_provider.models import AccessToken, IDToken +from arkid.core.models import Tenant, ExpiringToken + + +Application = get_application_model() + + +# class ConnectDiscoveryInfoView(OIDCOnlyMixin, View): +# """ +# View used to show oidc provider configuration information +# """ + +# def get(self, request, *args, **kwargs): +# from config import get_app_config + +# tenant = None +# tenant_uuid = kwargs.get('tenant_uuid') +# if tenant_uuid: +# tenant = Tenant.objects.get(uuid=tenant_uuid) + +# if not tenant: +# uuid_re = r"[0-9a-f]{8}\-[0-9a-f]{4}\-[0-9a-f]{4}\-[0-9a-f]{4}\-[0-9a-f]{12}" +# path = self.request.path +# res = re.search(uuid_re, path) +# if res: +# tenant_uuid = res.group(0) +# tenant = Tenant.objects.filter(uuid=tenant_uuid).first() + +# issuer_url = oauth2_settings.OIDC_ISS_ENDPOINT +# host = get_app_config().get_host() +# if not issuer_url: +# if tenant: +# issuer_url = oauth2_settings.oidc_issuer(request, tenant.uuid) +# authorization_endpoint = host+reverse("api:oauth2_authorization_server:authorize", args=[tenant.uuid]) +# token_endpoint = host+reverse("api:oauth2_authorization_server:token", args=[tenant.uuid]) +# userinfo_endpoint = oauth2_settings.OIDC_USERINFO_ENDPOINT or host+reverse("api:oauth2_authorization_server:oauth-user-info", args=[tenant.uuid]) +# jwks_uri = host+reverse("api:oauth2_authorization_server:jwks-info", args=[tenant.uuid]) +# else: +# issuer_url = oauth2_settings.oidc_issuer(request) +# authorization_endpoint = host+reverse("api:arkid_saas:authorize-platform") +# token_endpoint = host+reverse("api:arkid_saas:token-platform") +# userinfo_endpoint = oauth2_settings.OIDC_USERINFO_ENDPOINT or host+reverse("api:arkid_saas:oauth-user-info-platform") +# jwks_uri = host+reverse("api:arkid_saas:jwks-info-platform") +# else: +# if tenant: +# authorization_endpoint = "{}{}".format(issuer_url, reverse("api:oauth2_authorization_server:authorize")) +# token_endpoint = "{}{}".format(issuer_url, reverse("api:oauth2_authorization_server:token")) +# userinfo_endpoint = oauth2_settings.OIDC_USERINFO_ENDPOINT or "{}{}".format( +# issuer_url, reverse("api:oauth2_authorization_server:user-info") +# ) +# jwks_uri = "{}{}".format(issuer_url, reverse("api:oauth2_authorization_server:jwks-info")) +# else: +# authorization_endpoint = "{}{}".format(issuer_url, reverse("api:arkid_saas:authorize")) +# token_endpoint = "{}{}".format(issuer_url, reverse("api:arkid_saas:token")) +# userinfo_endpoint = oauth2_settings.OIDC_USERINFO_ENDPOINT or "{}{}".format( +# issuer_url, reverse("api:arkid_saas:user-info") +# ) +# jwks_uri = "{}{}".format(issuer_url, reverse("api:arkid_saas:jwks-info")) +# signing_algorithms = [Application.HS256_ALGORITHM] +# if oauth2_settings.OIDC_RSA_PRIVATE_KEY: +# signing_algorithms = [Application.RS256_ALGORITHM, Application.HS256_ALGORITHM] +# data = { +# "issuer": issuer_url, +# "authorization_endpoint": authorization_endpoint, +# "token_endpoint": token_endpoint, +# "userinfo_endpoint": userinfo_endpoint, +# "jwks_uri": jwks_uri, +# "response_types_supported": oauth2_settings.OIDC_RESPONSE_TYPES_SUPPORTED, +# "subject_types_supported": oauth2_settings.OIDC_SUBJECT_TYPES_SUPPORTED, +# "id_token_signing_alg_values_supported": signing_algorithms, +# "token_endpoint_auth_methods_supported": ( +# oauth2_settings.OIDC_TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED +# ), +# } +# response = JsonResponse(data) +# response["Access-Control-Allow-Origin"] = "*" +# return response + + +class ConnectDiscoveryInfoView(OIDCOnlyMixin, View): + """ + View used to show oidc provider configuration information per + `OpenID Provider Metadata `_ + """ + + def get(self, request, *args, **kwargs): + issuer_url = oauth2_settings.OIDC_ISS_ENDPOINT + + if not issuer_url: + issuer_url = oauth2_settings.oidc_issuer(request) + authorization_endpoint = request.build_absolute_uri(reverse("oauth2_provider:authorize")) + token_endpoint = request.build_absolute_uri(reverse("oauth2_provider:token")) + userinfo_endpoint = oauth2_settings.OIDC_USERINFO_ENDPOINT or request.build_absolute_uri( + reverse("oauth2_provider:user-info") + ) + jwks_uri = request.build_absolute_uri(reverse("oauth2_provider:jwks-info")) + else: + parsed_url = urlparse(oauth2_settings.OIDC_ISS_ENDPOINT) + host = parsed_url.scheme + "://" + parsed_url.netloc + authorization_endpoint = "{}{}".format(host, reverse("oauth2_provider:authorize")) + token_endpoint = "{}{}".format(host, reverse("oauth2_provider:token")) + userinfo_endpoint = oauth2_settings.OIDC_USERINFO_ENDPOINT or "{}{}".format( + host, reverse("oauth2_provider:user-info") + ) + jwks_uri = "{}{}".format(host, reverse("oauth2_provider:jwks-info")) + + signing_algorithms = [Application.HS256_ALGORITHM] + if oauth2_settings.OIDC_RSA_PRIVATE_KEY: + signing_algorithms = [Application.RS256_ALGORITHM, Application.HS256_ALGORITHM] + + validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS + validator = validator_class() + oidc_claims = list(set(validator.get_discovery_claims(request))) + scopes_class = oauth2_settings.SCOPES_BACKEND_CLASS + scopes = scopes_class() + scopes_supported = [scope for scope in scopes.get_available_scopes()] + + data = { + "issuer": issuer_url, + "authorization_endpoint": authorization_endpoint, + "token_endpoint": token_endpoint, + "userinfo_endpoint": userinfo_endpoint, + "jwks_uri": jwks_uri, + "scopes_supported": scopes_supported, + "response_types_supported": oauth2_settings.OIDC_RESPONSE_TYPES_SUPPORTED, + "subject_types_supported": oauth2_settings.OIDC_SUBJECT_TYPES_SUPPORTED, + "id_token_signing_alg_values_supported": signing_algorithms, + "token_endpoint_auth_methods_supported": ( + oauth2_settings.OIDC_TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED + ), + "claims_supported": oidc_claims, + } + response = JsonResponse(data) + response["Access-Control-Allow-Origin"] = "*" + return response + + +class JwksInfoView(OIDCOnlyMixin, View): + """ + View used to show oidc json web key set document + """ + + def get(self, request, *args, **kwargs): + keys = [] + if oauth2_settings.OIDC_RSA_PRIVATE_KEY: + key = jwk.JWK.from_pem(oauth2_settings.OIDC_RSA_PRIVATE_KEY.encode("utf8")) + data = {"alg": "RS256", "use": "sig", "kid": key.thumbprint()} + data.update(json.loads(key.export_public())) + keys.append(data) + response = JsonResponse({"keys": keys}) + response["Access-Control-Allow-Origin"] = "*" + return response + + +@method_decorator(csrf_exempt, name="dispatch") +class UserInfoView(OIDCOnlyMixin, OAuthLibMixin, View): + """ + View used to show Claims about the authenticated End-User + """ + + def get(self, request, *args, **kwargs): + return self._create_userinfo_response(request) + + def post(self, request, *args, **kwargs): + return self._create_userinfo_response(request) + + def _create_userinfo_response(self, request): + url, headers, body, status = self.create_userinfo_response(request) + response = HttpResponse(content=body or "", status=status) + for k, v in headers.items(): + response[k] = v + return response + + +@method_decorator(csrf_exempt, name="dispatch") +class UserInfoExtendView(UserInfoView): + """ + View used to show Claims about the authenticated End-User + """ + + def get(self, request, *args, **kwargs): + access_token = request.META.get('HTTP_AUTHORIZATION', '') + return self.get_user(request, access_token) + + def post(self, request, *args, **kwargs): + access_token = request.META.get('HTTP_AUTHORIZATION', '') + return self.get_user(request, access_token) + + def get_user(self, request, access_token): + if access_token: + access_token = access_token.split(' ')[1] + access_token = AccessToken.objects.filter(token=access_token).first() + if access_token: + user = access_token.user + data = {"id":user.id,"name":user.username,"email":user.email} + try: + response = self._create_userinfo_response(request) + resp_data = json.loads(response.content) + data.update(resp_data) + return JsonResponse(data) + except Exception as e: + return JsonResponse({"error": str(e)}) + else: + return JsonResponse({"error": "access_token 不存在"}) + else: + return JsonResponse({"error": "access_token 不能为空"}) + + +@method_decorator(csrf_exempt, name="dispatch") +class OIDCLogoutView(OIDCOnlyMixin, OAuthLibMixin, View): + """ + View used to show Claims about the authenticated End-User + """ + + def get(self, request, *args, **kwargs): + id_token_hint = request.GET.get('id_token_hint', '') + url = request.GET.get('post_logout_redirect_uri', '') + id_token = IDToken.objects.filter(token=id_token_hint).first() + if id_token: + user = id_token.user + ExpiringToken.objects.filter( + user=user + ).delete() + if url: + return HttpResponseRedirect(url) + else: + return JsonResponse({"error_code":0, 'error_msg':'logout success'}) + else: + return JsonResponse({"error_code":1, "error_msg": "id_token error"}) \ No newline at end of file diff --git a/oauth2_provider/views/token.py b/oauth2_provider/views/token.py new file mode 100644 index 000000000..f812d8db9 --- /dev/null +++ b/oauth2_provider/views/token.py @@ -0,0 +1,34 @@ +from django.contrib.auth.mixins import LoginRequiredMixin +from django.urls import reverse_lazy +from django.views.generic import DeleteView, ListView + +from ..models import get_access_token_model + + +class AuthorizedTokensListView(LoginRequiredMixin, ListView): + """ + Show a page where the current logged-in user can see his tokens so they can revoke them + """ + + context_object_name = "authorized_tokens" + template_name = "oauth2_provider/authorized-tokens.html" + model = get_access_token_model() + + def get_queryset(self): + """ + Show only user"s tokens + """ + return super().get_queryset().select_related("application").filter(user=self.request.user) + + +class AuthorizedTokenDeleteView(LoginRequiredMixin, DeleteView): + """ + View for revoking a specific token + """ + + template_name = "oauth2_provider/authorized-token-delete.html" + success_url = reverse_lazy("api:authorized-token-list") + model = get_access_token_model() + + def get_queryset(self): + return super().get_queryset().filter(user=self.request.user) diff --git a/requirements.txt b/requirements.txt index 34a3e1e62..139c1063d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -55,3 +55,5 @@ watchdog==2.1.7 wcwidth==0.2.5 wrapt==1.14.0 zipp==3.8.0 +jwcrypto==1.0 +oauthlib==3.2.0 diff --git a/test1.py b/test1.py new file mode 100644 index 000000000..724acc5a7 --- /dev/null +++ b/test1.py @@ -0,0 +1,20 @@ +import os +import uuid +import django +import collections + +# 导入settings +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "arkid.settings") +# 安装django +django.setup() + +def update_url(): + from django.urls import include, re_path + urls = [ + re_path(r'^o/', include('oauth2_provider.urls')) + ] + print(urls) + + +if __name__ == "__main__": + update_url() \ No newline at end of file