diff --git a/README.md b/README.md index dd2efb2..abde774 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # setup +django-janus is an [OAuth2](https://www.rfc-editor.org/rfc/rfc6749) authorization server. +The [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html) and [Discovery 1.0](https://openid.net/specs/openid-connect-discovery-1_0.html) Standards are implemented. + ## installation `pip install git+https://github.com/smartlgt/django-janus#egg=django-janus` @@ -8,7 +11,7 @@ add to installed apps: -``` +```python3 INSTALLED_APPS = [ # other apps @@ -27,17 +30,17 @@ INSTALLED_APPS = [ ``` Set a fix site ID or init the database table via manage commands: -``` +```python3 SITE_ID = 1 ``` Oauth config: -``` +```python3 OAUTH2_PROVIDER_APPLICATION_MODEL = 'oauth2_provider.Application' ``` cors for web apps: -``` +```python3 MIDDLEWARE = ( # ... 'corsheaders.middleware.CorsMiddleware', @@ -54,7 +57,7 @@ CORS_URLS_REGEX = r"^/oauth2/.*$" its possible to use any social login, reffer the allauth docs for configuration. Allauth config e.G.: -``` +```python3 ACCOUNT_EMAIL_REQUIRED = False ACCOUNT_UNIQUE_EMAIL = True ACCOUNT_EMAIL_VERIFICATION = "optional" @@ -65,7 +68,7 @@ ACCOUNT_LOGOUT_ON_GET = True ``` E-Mail config e.G.: -``` +```python3 EMAIL_USE_SSL = True EMAIL_HOST = 'smtp.example.com' EMAIL_HOST_USER = 'mail@example.com' @@ -76,7 +79,7 @@ DEFAULT_FROM_EMAIL = 'name ' ``` (recommended) cleanup old token -``` +```python3 CELERY_BEAT_SCHEDULE = { 'cleanup_token': { 'task': 'janus.tasks.cleanup_token', @@ -85,8 +88,26 @@ CELERY_BEAT_SCHEDULE = { } ``` -(optional) setup your ldap server +(optional) enable and configure OIDC + +```python3 +# Scope in which additional claims are included. These claims are is_staff, is_superuser and groups. +JANUS_OIDC_SCOPE_EXTRA = "profile" +OAUTH2_PROVIDER = { + "OIDC_ENABLED": True, # Enable OIDC + "OIDC_ISS_ENDPOINT": "[...]", + "OAUTH2_VALIDATOR_CLASS": "janus.oauth2.validator.JanusOAuth2Validator", + "OIDC_RSA_PRIVATE_KEY": "[...]", # Generate with `openssl genrsa -out oidc.key 4096` + "SCOPES": { # Claims are returned based on granted scopes. See OIDC Core section 5.4. + "openid": "Connect with your Account", + "profile": "Access your Name and Username", + "email": "Access your Mail-Address", + } +} ``` + +(optional) setup your ldap server +```python3 # The URL of the LDAP server. LDAP_AUTH_URL = "ldap.exmaple.com" @@ -114,7 +135,7 @@ AUTHENTICATION_BACKENDS = ( ## urls.py -``` +```python3 urlpatterns = [ path('admin/', admin.site.urls), path('accounts/', include('allauth.urls')), @@ -125,7 +146,7 @@ urlpatterns = [ ## first run migrate your database -``` +```bash ./manage.py migrate ``` @@ -134,7 +155,7 @@ if you open a browser and look at the index page you should see `Hello from janu # usage -## endpoints +## OAuth2 endpoints ### o/authorize/ OAuth2 authorize endpoint @@ -144,6 +165,9 @@ OAuth2 access token endpoint ### o/revoke_token/ OAuth2 revoke access or refresh tokens +### o/introspect/ +OAuth2 introspection endpoint. Requires `introspect` scope. + ### o/profile/ custom profile endpoint, returns user profile information as json: ````json @@ -161,11 +185,12 @@ custom profile endpoint, returns user profile information as json: ```` #### extend profile response +##### Old overwrite settings like this: `ALLAUTH_JANUS_PROFILE_VIEW = 'app.views.ProfileViewCustom'` add a new profie view class and customize as needed -``` +```python3 from janus.views import ProfileView class ProfileViewCustom(ProfileView): @@ -175,10 +200,24 @@ class ProfileViewCustom(ProfileView): return data ``` +##### New (OIDC) +Extend `JanusOAuth2Validator`. You can then modify the response by [adding additional information to the `UserInfo` service directly](https://django-oauth-toolkit.readthedocs.io/en/latest/oidc.html#adding-information-to-the-userinfo-service) or by [adding claims to the ID token](https://django-oauth-toolkit.readthedocs.io/en/latest/oidc.html#adding-claims-to-the-id-token). +Then set the modified Validator as `OAUTH2_VALIDATOR_CLASS` in the `OAUTH2_PROVIDER` in the settings. See also the section on `enable and configure OIDC` for the configuration of the settings. + +## OIDC endpoints +### `o/userinfo/` +UserInfo endpoint as per section 5.3 of OpenID Connect Core 1.0. + +### `o/.well-known/openid-configuration/` +OpenID Provider Configuration endpoint as per section 4 of OpenID Connect Discovery 1.0. + +### `o/.well-known/jwks.json` +JSON Web Key Set endpoint as per section 3 of OpenID Connect Discovery 1.0. + ## admin custom user class set `ALLAUTH_JANUS_ADMIN_CLASS = 'app.admin_custom.CustomUserAdmin'` -``` +```python3 from janus.admin import JanusUserAdmin class CustomUserAdmin(JanusUserAdmin): diff --git a/janus/__init__.py b/janus/__init__.py index b4e70c0..4f619c3 100644 --- a/janus/__init__.py +++ b/janus/__init__.py @@ -1 +1 @@ -__version__ = VERSION = (1, 3, 1) +__version__ = VERSION = (1, 3, 2) diff --git a/janus/admin.py b/janus/admin.py index 32b1caa..3a23e3d 100644 --- a/janus/admin.py +++ b/janus/admin.py @@ -10,6 +10,7 @@ ApplicationExtension from django.contrib.auth.admin import UserAdmin +from janus.oauth2.util import get_group_list ################################################### @@ -54,15 +55,12 @@ def is_multipart(self): def get_applications(self): user = self.instance - from janus.views import ProfileView - pv = ProfileView() - ret = {} applications = get_application_model().objects.all() for application in applications: - ret[application.name] = pv.get_group_list(user, application) + ret[application.name] = get_group_list(user, application) return ret diff --git a/janus/app_settings.py b/janus/app_settings.py index f7bc5be..3e5695e 100644 --- a/janus/app_settings.py +++ b/janus/app_settings.py @@ -3,3 +3,10 @@ ALLAUTH_JANUS_PROFILE_VIEW = getattr(settings, 'ALLAUTH_JANUS_PROFILE_VIEW', 'janus.views.ProfileView') ALLAUTH_JANUS_ADMIN_CLASS = getattr(settings, 'ALLAUTH_JANUS_ADMIN_CLASS', 'janus.admin.JanusUserAdmin') + +# Enable OIDC only if it is enabled in django-oauth-toolkit. +OAUTH_SETTINGS = getattr(settings, 'OAUTH2_PROVIDER', {}) +JANUS_OIDC_ENABLED = OAUTH_SETTINGS.get('OIDC_ENABLED', False) + +# janus supports some non-standard claims. Set which scope is required to return the claims. +JANUS_OIDC_SCOPE_EXTRA = getattr(settings, 'JANUS_OIDC_SCOPE_EXTRA', 'janus') diff --git a/janus/oauth2/util.py b/janus/oauth2/util.py new file mode 100644 index 0000000..650d61b --- /dev/null +++ b/janus/oauth2/util.py @@ -0,0 +1,145 @@ +from janus.models import ProfileGroup, Profile, GroupPermission, ProfilePermission + + +def get_profile_memberships(user): + all_profiles = Profile.objects.get(user=user).group.all() + + # add the default groups by default + default_profile = ProfileGroup.objects.filter(default=True) + all_profiles = set(all_profiles | default_profile) + + return list(all_profiles) + + +def get_group_permissions(user, application): + """ + Validates the group permissions for a user given a token + :param user: + :param token: + :return: + """ + is_staff = False + is_superuser = False + can_authenticate = False + + if not user.is_authenticated: + return can_authenticate, is_staff, is_superuser + + all_groups = get_profile_memberships(user) + + for g in all_groups: + gp = GroupPermission.objects.filter(profile_group=g, application=application) + if gp.count() == 0: + continue + elif gp.count() == 1: + gp = gp.first() + if gp.can_authenticate: + can_authenticate = True + if gp.is_staff: + is_staff = True + if gp.is_superuser: + is_superuser = True + else: + print('We have a problem') + return can_authenticate, is_staff, is_superuser + + +def get_personal_permissions(user, application): + """ + Validates the personal permissions for a user given a token + :param user: + :param token: + :return: + """ + is_staff = None + is_superuser = None + can_authenticate = None + if not user.is_authenticated: + return can_authenticate, is_staff, is_superuser + + pp = ProfilePermission.objects.filter(profile__user=user, application=application).first() + if pp: + is_staff = True if pp.is_staff else None + is_superuser = True if pp.is_superuser else None + can_authenticate = True if pp.can_authenticate else None + return can_authenticate, is_staff, is_superuser + + +def get_profile_group_memberships(user, application): + """ + collect group names form user profile group memberships + :param user: + :param token: + :return: + """ + + all_profiles = get_profile_memberships(user) + + group_list = set() + + for g in all_profiles: + # get profile-group-permission object + gp = GroupPermission.objects.filter(profile_group=g, application=application) + for elem in gp: + groups = elem.groups.all() + + for g in groups: + # ensure only groups for this application can be returned + if g.application == application: + group_list.add(g.name) + + return group_list + + +def get_profile_personal_memberships(user, application): + """ + collect group names form user profile permission + :param user: + :param token: + :return: + """ + + profile_permissions = ProfilePermission.objects.filter(profile__user=user, application=application).first() + + group_list = set() + + if profile_permissions: + groups = profile_permissions.groups.all() + + for g in groups: + # ensure only groups for this application can be returned + if g.application == application: + group_list.add(g.name) + + return group_list + + +def get_permissions(user, application): + """ + return permissions according to application settings, personal overwrite and default values + :param user: + :param application: + :return: + """ + can_authenticate, is_staff, is_superuser = get_group_permissions(user, application) + + # if set the personal settings overwrite the user settings + pp_authenticate, pp_staff, pp_superuser = get_personal_permissions(user, application) + if pp_staff is not None: + is_staff = pp_staff + + if pp_superuser is not None: + is_superuser = pp_superuser + + if pp_authenticate is not None: + can_authenticate = pp_authenticate + + return can_authenticate, is_staff, is_superuser + + +def get_group_list(user, application): + groups = set() + groups = groups.union(get_profile_group_memberships(user, application)) + groups = groups.union(get_profile_personal_memberships(user, application)) + + return list(groups) diff --git a/janus/oauth2/validator.py b/janus/oauth2/validator.py new file mode 100644 index 0000000..e0d9bf3 --- /dev/null +++ b/janus/oauth2/validator.py @@ -0,0 +1,45 @@ +from allauth.account.models import EmailAddress +from oauth2_provider.oauth2_validators import OAuth2Validator + +from janus import app_settings +from janus.oauth2.util import get_permissions, get_group_list + + +class JanusOAuth2Validator(OAuth2Validator): + # As per 5.4 of the OIDC Core Specs the OIDC Scopes are used to determine which claims are returned. + # See https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims + # Update with additional non-standard claims we support. + oidc_claim_scope = OAuth2Validator.oidc_claim_scope + oidc_claim_scope.update({"is_staff": app_settings.JANUS_OIDC_SCOPE_EXTRA, + "is_superuser": app_settings.JANUS_OIDC_SCOPE_EXTRA, + "can_authenticate": app_settings.JANUS_OIDC_SCOPE_EXTRA, + "groups": app_settings.JANUS_OIDC_SCOPE_EXTRA + }) + + def get_additional_claims(self, request): + # The default implementation only returns very little data. + # Return the data for the additional claims that we want support. + + user = request.user + + can_authenticate, is_staff, is_superuser = get_permissions(user, request.client) + + # The `sub` claim is a unique id for a user. The `sub` claim is always returned. + return { + "name": ' '.join([user.first_name, user.last_name]), + "given_name": user.first_name, + "family_name": user.last_name, + "preferred_username": user.username, + "email": user.email, + "email_verified": EmailAddress.objects.filter(user=user, verified=True).exists(), + "is_staff": is_staff, + "is_superuser": is_superuser, + # TODO: Is `can_authenticate` required? + "can_authenticate": can_authenticate, + "groups": get_group_list(user, request.client), + } + + def get_discovery_claims(self, request): + # Used for discovery of the available claims at the Auto Discovery Endpoint. + return ["name", "given_name", "family_name", "preferred_username", "email", "email_verified", "is_staff", + "is_superuser", "can_authenticate", "groups"] diff --git a/janus/tests/test_basics.py b/janus/tests/test_basics.py index 94e0a82..03e80d1 100644 --- a/janus/tests/test_basics.py +++ b/janus/tests/test_basics.py @@ -12,6 +12,8 @@ from janus.models import ProfileGroup, Profile, GroupPermission, ProfilePermission, ApplicationGroup, \ ApplicationExtension +from janus.oauth2.util import get_profile_group_memberships, get_personal_permissions, get_group_permissions, \ + get_profile_memberships, get_permissions from janus.views import ProfileView User = get_user_model() @@ -327,29 +329,28 @@ def test_replace_keys_by_application(self): def test_profile_view_groups(self): # check ProfileView - pv = ProfileView() # add users to groups self.user_staff.profile.group.add(self.group_staff) self.user_staff.profile.group.add(self.group_customer) - list_group_names = pv.get_profile_group_memberships(self.user_staff, self.application_one) + list_group_names = get_profile_group_memberships(self.user_staff, self.application_one) self.assertIn("django_user_staff", list_group_names) self.assertIn("django_customer", list_group_names) - list_empty_groups = pv.get_profile_group_memberships(self.user_customer, self.application_one) + list_empty_groups = get_profile_group_memberships(self.user_customer, self.application_one) self.assertAlmostEqual(len(list_empty_groups), 0) # test super user has on app2 -> group1, group2 self.user_admin.profile.group.add(self.group_superuser) - list_two_not_three = pv.get_profile_group_memberships(self.user_admin, self.application_two) + list_two_not_three = get_profile_group_memberships(self.user_admin, self.application_two) self.assertIn("group1", list_two_not_three) self.assertIn("group2", list_two_not_three) self.assertNotIn("group3", list_two_not_three) # also group3 is only linked to the wrong app so its also not present in the desired app - list_empty_again = pv.get_profile_group_memberships(self.user_admin, self.application_one) + list_empty_again = get_profile_group_memberships(self.user_admin, self.application_one) self.assertAlmostEqual(len(list_empty_again), 0) @@ -385,29 +386,27 @@ def setUp(self): def test_personal_permissions(self): - pv = ProfileView() - self.assertEqual(True, self.user_staff.is_authenticated) - pvr = pv.get_personal_permissions(self.user_staff, self.app) + pvr = get_personal_permissions(self.user_staff, self.app) self.assertEqual((None, None, None), pvr) pp = ProfilePermission.objects.create(profile=self.user_staff.profile, application=self.app, can_authenticate=True) - pvr = pv.get_personal_permissions(self.user_staff, self.app) + pvr = get_personal_permissions(self.user_staff, self.app) self.assertEqual((True, None, None), pvr) pp.is_staff = True pp.save() - pvr = pv.get_personal_permissions(self.user_staff, self.app) + pvr = get_personal_permissions(self.user_staff, self.app) self.assertEqual((True, True, None), pvr) pp.is_staff = False pp.is_superuser = True pp.save() - pvr = pv.get_personal_permissions(self.user_staff, self.app) + pvr = get_personal_permissions(self.user_staff, self.app) self.assertEqual((True, None, True), pvr) @@ -439,45 +438,43 @@ def setUp(self): is_superuser=True) def test_group_permissions(self): - pv = ProfileView() - result = pv.get_group_permissions(self.user, self.application_1) + result = get_group_permissions(self.user, self.application_1) self.assertEqual((True, False, True), result) self.gp1.is_staff = True self.gp1.save() - result = pv.get_group_permissions(self.user, self.application_1) + result = get_group_permissions(self.user, self.application_1) self.assertEqual((True, True, True), result) self.gp1.is_superuser = False self.gp1.save() - result = pv.get_group_permissions(self.user, self.application_1) + result = get_group_permissions(self.user, self.application_1) self.assertEqual((True, True, False), result) - members = pv.get_profile_memberships(self.user) + members = get_profile_memberships(self.user) with mock.patch('janus.views.ProfileView.get_profile_memberships') as m: m.return_value = reversed(members) pv = ProfileView() - result = pv.get_group_permissions(self.user, self.application_1) + result = get_group_permissions(self.user, self.application_1) self.assertEqual((True, True, False), result) with mock.patch('janus.views.ProfileView.get_profile_memberships') as m: m.return_value = members pv = ProfileView() - result = pv.get_group_permissions(self.user, self.application_1) + result = get_group_permissions(self.user, self.application_1) self.assertEqual((True, True, False), result) def test_permissions(self): - pv = ProfileView() - result = pv.get_permissions(self.user, self.application_1) + result = get_permissions(self.user, self.application_1) self.assertEqual((True, False, True), result) self.gp1.is_staff = True self.gp1.save() - result = pv.get_permissions(self.user, self.application_1) + result = get_permissions(self.user, self.application_1) self.assertEqual((True, True, True), result) @@ -494,6 +491,5 @@ def test_json(self): def test_membership(self): - pv = ProfileView() - ret = pv.get_profile_memberships(self.user) + ret = get_profile_memberships(self.user) print(ret) diff --git a/janus/urls.py b/janus/urls.py index a895e5f..4662ee3 100644 --- a/janus/urls.py +++ b/janus/urls.py @@ -1,25 +1,37 @@ from django.conf.urls import include from django.urls import path, re_path from django.utils.module_loading import import_string -from oauth2_provider.views import TokenView, RevokeTokenView, IntrospectTokenView +from oauth2_provider.views import TokenView, RevokeTokenView, IntrospectTokenView, UserInfoView, \ + ConnectDiscoveryInfoView, JwksInfoView from janus import views from janus.oauth2.views import AuthorizationView - +from janus.app_settings import JANUS_OIDC_ENABLED # override profile view if django settings point to a different class from . import app_settings as janus_app_settings ProfileViewClass = import_string(janus_app_settings.ALLAUTH_JANUS_PROFILE_VIEW) - -urlpatterns = [ +oauth2_provider_patterns = [ # use custom view, to enforce the user authenticate permissions - re_path(r'^o/authorize/?$', AuthorizationView.as_view(), name="authorize"), + re_path(r'^authorize/?$', AuthorizationView.as_view(), name="authorize"), # default oauth2_provider.url.base_urlpatterns - re_path(r'^o/token/?$', TokenView.as_view(), name="token"), - re_path(r'^o/revoke_token/?$', RevokeTokenView.as_view(), name="revoke-token"), + re_path(r'^token/?$', TokenView.as_view(), name="token"), + re_path(r'^revoke_token/?$', RevokeTokenView.as_view(), name="revoke-token"), re_path(r"^introspect/?$", IntrospectTokenView.as_view(), name="introspect"), +] + +if JANUS_OIDC_ENABLED: + oauth2_provider_patterns += [ + re_path(r'^userinfo/', UserInfoView.as_view(), name='user-info'), + re_path(r"^\.well-known/openid-configuration", ConnectDiscoveryInfoView.as_view(), name="oidc-connect-discovery-info"), + re_path(r"^\.well-known/jwks.json$", JwksInfoView.as_view(), name="jwks-info"), + ] + +urlpatterns = [ + # include oauth2 related urls + re_path(r'o/', include((oauth2_provider_patterns, "oauth2_provider"))), # custom urls re_path(r'^o/profile/?$', ProfileViewClass.as_view(), name="profile"), diff --git a/janus/views.py b/janus/views.py index f691b6b..64db507 100644 --- a/janus/views.py +++ b/janus/views.py @@ -9,8 +9,7 @@ from oauth2_provider.views import ProtectedResourceView import json -from janus.models import ProfileGroup, Profile, GroupPermission, ProfilePermission, \ - ApplicationExtension +from janus.oauth2.util import get_permissions, get_group_list class LogoutView(View): @@ -54,149 +53,6 @@ def clean_user_tokens(self, user): class ProfileView(ProtectedResourceView): - def get_profile_memberships(self, user): - - all_profiles = Profile.objects.get(user=user).group.all() - - # add the default groups by default - default_profile = ProfileGroup.objects.filter(default=True) - all_profiles = set(all_profiles | default_profile) - - return list(all_profiles) - - def get_group_permissions(self, user, application): - """ - Validates the group permissions for a user given a token - :param user: - :param token: - :return: - """ - is_staff = False - is_superuser = False - can_authenticate = False - - if not user.is_authenticated: - return can_authenticate, is_staff, is_superuser - - all_groups = self.get_profile_memberships(user) - - for g in all_groups: - gp = GroupPermission.objects.filter(profile_group=g, application=application) - if gp.count() == 0: - continue - elif gp.count() == 1: - gp = gp.first() - if gp.can_authenticate: - can_authenticate = True - if gp.is_staff: - is_staff = True - if gp.is_superuser: - is_superuser = True - else: - print('We have a problem') - return can_authenticate, is_staff, is_superuser - - def get_personal_permissions(self, user, application): - """ - Validates the personal permissions for a user given a token - :param user: - :param token: - :return: - """ - is_staff = None - is_superuser = None - can_authenticate = None - if not user.is_authenticated: - return can_authenticate, is_staff, is_superuser - - pp = ProfilePermission.objects.filter(profile__user=user, application=application).first() - if pp: - is_staff = True if pp.is_staff else None - is_superuser = True if pp.is_superuser else None - can_authenticate = True if pp.can_authenticate else None - return can_authenticate, is_staff, is_superuser - - - def get_profile_group_memberships(self, user, application): - """ - collect group names form user profile group memberships - :param user: - :param token: - :return: - """ - - all_profiles = self.get_profile_memberships(user) - - group_list = set() - - for g in all_profiles: - # get profile-group-permission object - gp = GroupPermission.objects.filter(profile_group=g, application=application) - for elem in gp: - groups = elem.groups.all() - - for g in groups: - # ensure only groups for this application can be returned - if g.application == application: - group_list.add(g.name) - - return group_list - - - def get_profile_personal_memberships(self, user, application): - """ - collect group names form user profile permission - :param user: - :param token: - :return: - """ - - profile_permissions = ProfilePermission.objects.filter(profile__user=user, application=application).first() - - group_list = set() - - if profile_permissions: - groups = profile_permissions.groups.all() - - for g in groups: - # ensure only groups for this application can be returned - if g.application == application: - group_list.add(g.name) - - return group_list - - def get_permissions(self, user, application): - """ - return permissions according to application settings, personal overwrite and default values - :param user: - :param application: - :return: - """ - can_authenticate, is_staff, is_superuser = self.get_group_permissions(user, application) - - # if set the personal settings overwrite the user settings - pp_authenticate, pp_staff, pp_superuser = self.get_personal_permissions(user, application) - if pp_staff is not None: - is_staff = pp_staff - - if pp_superuser is not None: - is_superuser = pp_superuser - - if pp_authenticate is not None: - can_authenticate = pp_authenticate - - return can_authenticate, is_staff, is_superuser - - - def get_group_list(self, user, application): - - groups = set() - groups = groups.union(self.get_profile_group_memberships(user, application)) - groups = groups.union(self.get_profile_personal_memberships(user, application)) - - return list(groups) - - def get(self, request): if request.resource_owner: user = request.resource_owner @@ -235,9 +91,9 @@ def generate_json_data(self, user, application): :return: """ - can_authenticate, is_staff, is_superuser = self.get_permissions(user, application) + can_authenticate, is_staff, is_superuser = get_permissions(user, application) - groups = self.get_group_list(user, application) + groups = get_group_list(user, application) data = { 'id': user.username,