From f17200229477f42979346ff8034df13c9a309ced Mon Sep 17 00:00:00 2001 From: realaravinth Date: Mon, 22 Aug 2022 13:18:15 +0530 Subject: [PATCH] feat: add oidc app creation management cmd --- Makefile | 2 +- accounts/__init__.py | 0 accounts/admin.py | 3 + accounts/apps.py | 6 ++ accounts/management/commands/create_oidc.py | 110 ++++++++++++++++++++ accounts/migrations/__init__.py | 0 accounts/models.py | 3 + accounts/tests.py | 104 ++++++++++++++++++ accounts/utils.py | 30 ++++++ accounts/views.py | 3 + sso/asgi.py | 2 +- sso/settings.py | 78 +++++++------- sso/urls.py | 4 +- sso/wsgi.py | 2 +- 14 files changed, 303 insertions(+), 44 deletions(-) create mode 100644 accounts/__init__.py create mode 100644 accounts/admin.py create mode 100644 accounts/apps.py create mode 100644 accounts/management/commands/create_oidc.py create mode 100644 accounts/migrations/__init__.py create mode 100644 accounts/models.py create mode 100644 accounts/tests.py create mode 100644 accounts/utils.py create mode 100644 accounts/views.py diff --git a/Makefile b/Makefile index b6bba28..c501531 100644 --- a/Makefile +++ b/Makefile @@ -22,7 +22,7 @@ help: ## Prints help for targets with comments @cat $(MAKEFILE_LIST) | grep -E '^[a-zA-Z_-]+:.*?## .*$$' | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' lint: ## Run linter - @./venv/bin/black dashboard accounts dash support billing infrastructure integration + @./venv/bin/black accounts sso migrate: ## Run migrations $(call run_migrations) diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..0cb51e6 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" diff --git a/accounts/management/commands/create_oidc.py b/accounts/management/commands/create_oidc.py new file mode 100644 index 0000000..c33811a --- /dev/null +++ b/accounts/management/commands/create_oidc.py @@ -0,0 +1,110 @@ +# Copyright © 2022 Aravinth Manivannan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from django.core.management.base import BaseCommand +from django.core.exceptions import ValidationError +from django.conf import settings +from django.contrib.auth import get_user_model + +from oauth2_provider.models import get_application_model +from oauth2_provider.generators import generate_client_id, generate_client_secret + +from accounts.utils import gen_secret + +Application = get_application_model() + + +class Command(BaseCommand): + help = "Get user ID from username" + app_name_key = "app_name" + username_key = "username" + redirect_uri_key = "redirect_uri" + + def add_arguments(self, parser): + parser.add_argument(self.app_name_key, type=str, help="The application name") + + parser.add_argument( + self.username_key, + type=str, + help="The username of user who will own this app", + ) + + parser.add_argument( + self.redirect_uri_key, + type=str, + help="The username of user who will own this app", + ) + + def handle(self, *args, **options): + if self.username_key not in options: + self.stdout.write(self.style.ERROR("Please provide username")) + return + if self.app_name_key not in options: + self.stdout.write(self.style.ERROR("Please provide application name")) + return + + if self.redirect_uri_key not in options: + self.stdout.write(self.style.ERROR("Please provide redirect uri")) + return + + username = options[self.username_key] + application_name = options[self.app_name_key] + redirect_uri = options[self.redirect_uri_key] + + User = get_user_model() + if not User.objects.filter(username=username).exists(): + self.stderr.write(self.style.ERROR(f"user {username} not found")) + return + + user = User.objects.get(username=username) + # python manage.py createapplication --name demo-oidc-app --user 1 --client-id 22500acb0bcfcba137d6b8ae96d3f2 --client-secret 296055337620b0e443ad24a32cb675 --algorithm HS256 --skip-authorization --redirect-uri http://example.org/uri1 confidential code -v + + client_id = generate_client_id() + client_secret = generate_client_secret() + config = { + "name": application_name, + "user_id": user.id, + "client_id": client_id, + "client_secret": client_secret, + "algorithm": "HS256", + "skip_authorization": True, + "redirect_uris": redirect_uri, + "authorization_grant_type": "authorization-code", + "client_type": "confidential", + } + + app = Application(**config) + + try: + app.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: + app.save() + self.stdout.write( + self.style.SUCCESS( + f"New application {application_name} created successfully." + ) + ) + self.stdout.write(f"client_id: {client_id}") + self.stdout.write(f"client_secret: {client_secret}") diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/accounts/models.py b/accounts/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/accounts/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/accounts/tests.py b/accounts/tests.py new file mode 100644 index 0000000..406b5dd --- /dev/null +++ b/accounts/tests.py @@ -0,0 +1,104 @@ +# Create your tests here. + +# Copyright © 2022 Aravinth Manivannan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +import time +import os +from io import StringIO +from urllib.parse import urlparse, urlunparse + +import requests + +from django.core import mail +from django.contrib.auth import get_user_model +from django.core.management import call_command +from django.urls import reverse +from django.test import TestCase, Client, override_settings +from django.utils.http import urlencode +from django.contrib.auth import authenticate +from django.conf import settings + + +from oauth2_provider.models import get_application_model + + +def register_util(t: TestCase, username: str): + t.password = "asdklfja;ldkfja;df" + t.username = username + t.email = f"{t.username}@example.org" + t.user = get_user_model().objects.create( + username=t.username, + email=t.email, + ) + t.user.set_password(t.password) + t.user.save() + + +def login_util(t: TestCase, c: Client, redirect_to: str): + payload = { + "login": t.username, + "password": t.password, + } + resp = c.post(reverse("accounts.login"), payload) + t.assertEqual(resp.status_code, 302) + t.assertEqual(resp.headers["location"], reverse(redirect_to)) + + +class CreateOidCApplicaiton(TestCase): + """ + Test command: manage.py create_oidc + """ + + def setUp(self): + self.username = "oidcadmin" + register_util(t=self, username=self.username) + + def test_cmd(self): + + Application = get_application_model() + + stdout = StringIO() + stderr = StringIO() + + redirect_uri = "http://example.org" + app_name = "test_cmd_oidc" + + # username exists + call_command( + "create_oidc", + app_name, + self.username, + redirect_uri, + stdout=stdout, + stderr=stderr, + ) + out = stdout.getvalue() + + self.assertIn(f"New application {app_name} created successfully", out) + + client_id = out.split("\n")[1].split(" ")[1] + + self.assertEqual( + get_application_model().objects.filter(client_id=client_id).exists(), True + ) + app = get_application_model().objects.get(name=app_name) + self.assertEqual(app.client_id, client_id) + self.assertEqual(app.name, app_name) + self.assertEqual(app.user_id, self.user.id) + self.assertEqual(app.redirect_uris, redirect_uri) + self.assertEqual(app.skip_authorization, True) + self.assertEqual(app.client_type, "confidential") + self.assertEqual(app.authorization_grant_type, "authorization-code") + self.assertEqual(app.algorithm, "HS256") diff --git a/accounts/utils.py b/accounts/utils.py new file mode 100644 index 0000000..5aba708 --- /dev/null +++ b/accounts/utils.py @@ -0,0 +1,30 @@ +# Copyright © 2022 Aravinth Manivannan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from datetime import datetime, timezone + +from django.utils.crypto import get_random_string +from django.core.mail import send_mail +from django.shortcuts import redirect +from django.template.loader import render_to_string +from django.urls import reverse +from django.utils.http import urlencode +from django.conf import settings + + +def gen_secret() -> str: + """ + Generate random secret + """ + return get_random_string(32) diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/accounts/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/sso/asgi.py b/sso/asgi.py index fdf2a66..bdd39bd 100644 --- a/sso/asgi.py +++ b/sso/asgi.py @@ -11,6 +11,6 @@ import os from django.core.asgi import get_asgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'sso.settings') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "sso.settings") application = get_asgi_application() diff --git a/sso/settings.py b/sso/settings.py index 86f9680..c31137b 100644 --- a/sso/settings.py +++ b/sso/settings.py @@ -20,7 +20,7 @@ BASE_DIR = Path(__file__).resolve().parent.parent # See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = 'django-insecure-xj=ic)(d#6(wuw7z=8y0vv_+wxcg)k^a+cpp53$7s5do^06&@a' +SECRET_KEY = "django-insecure-xj=ic)(d#6(wuw7z=8y0vv_+wxcg)k^a+cpp53$7s5do^06&@a" # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True @@ -31,54 +31,54 @@ ALLOWED_HOSTS = [] # Application definition INSTALLED_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'oauth2_provider', - 'accounts', + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "oauth2_provider", + "accounts", ] MIDDLEWARE = [ - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", ] -ROOT_URLCONF = 'sso.urls' +ROOT_URLCONF = "sso.urls" TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", ], }, }, ] -WSGI_APPLICATION = 'sso.wsgi.application' +WSGI_APPLICATION = "sso.wsgi.application" # Database # https://docs.djangoproject.com/en/4.1/ref/settings/#databases DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': BASE_DIR / 'db.sqlite3', + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", } } @@ -88,16 +88,16 @@ DATABASES = { AUTH_PASSWORD_VALIDATORS = [ { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", }, ] @@ -105,9 +105,9 @@ AUTH_PASSWORD_VALIDATORS = [ # Internationalization # https://docs.djangoproject.com/en/4.1/topics/i18n/ -LANGUAGE_CODE = 'en-us' +LANGUAGE_CODE = "en-us" -TIME_ZONE = 'UTC' +TIME_ZONE = "UTC" USE_I18N = True @@ -117,16 +117,16 @@ USE_TZ = True # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/4.1/howto/static-files/ -STATIC_URL = 'static/' +STATIC_URL = "static/" # Default primary key field type # https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field -DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" # OIDC django-oauth-toolkit -LOGIN_URL='/admin/login/' +LOGIN_URL = "/admin/login/" OAUTH2_PROVIDER = { "OIDC_ENABLED": True, diff --git a/sso/urls.py b/sso/urls.py index 621481f..0b2bdcb 100644 --- a/sso/urls.py +++ b/sso/urls.py @@ -17,6 +17,6 @@ from django.contrib import admin from django.urls import path, include urlpatterns = [ - path('admin/', admin.site.urls), - path('o/', include('oauth2_provider.urls', namespace='oauth2_provider')), + path("admin/", admin.site.urls), + path("o/", include("oauth2_provider.urls", namespace="oauth2_provider")), ] diff --git a/sso/wsgi.py b/sso/wsgi.py index 2cecca8..9c106b8 100644 --- a/sso/wsgi.py +++ b/sso/wsgi.py @@ -11,6 +11,6 @@ import os from django.core.wsgi import get_wsgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'sso.settings') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "sso.settings") application = get_wsgi_application()