feat: add oidc app creation management cmd

This commit is contained in:
Aravinth Manivannan 2022-08-22 13:18:15 +05:30
parent 0b13bb8636
commit f172002294
Signed by: realaravinth
GPG key ID: AD9F0F08E855ED88
14 changed files with 303 additions and 44 deletions

View file

@ -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)

0
accounts/__init__.py Normal file
View file

3
accounts/admin.py Normal file
View file

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

6
accounts/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "accounts"

View file

@ -0,0 +1,110 @@
# Copyright © 2022 Aravinth Manivannan <realaravinth@batsense.net>
#
# 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 <http://www.gnu.org/licenses/>.
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}")

View file

3
accounts/models.py Normal file
View file

@ -0,0 +1,3 @@
from django.db import models
# Create your models here.

104
accounts/tests.py Normal file
View file

@ -0,0 +1,104 @@
# Create your tests here.
# Copyright © 2022 Aravinth Manivannan <realaravinth@batsense.net>
#
# 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 <http://www.gnu.org/licenses/>.
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")

30
accounts/utils.py Normal file
View file

@ -0,0 +1,30 @@
# Copyright © 2022 Aravinth Manivannan <realaravinth@batsense.net>
#
# 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 <http://www.gnu.org/licenses/>.
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)

3
accounts/views.py Normal file
View file

@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

View file

@ -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()

View file

@ -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,

View file

@ -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")),
]

View file

@ -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()