Merge pull request 'feat: install django-oauth-toolkit to implement OpenID Connect' (#1) from realaravinth/mystiq-sso:wip-oidc into master
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Reviewed-on: #1
This commit is contained in:
commit
1f8437dd72
17 changed files with 403 additions and 43 deletions
|
@ -3,5 +3,6 @@ pipeline:
|
|||
image: python
|
||||
commands:
|
||||
- pip install virtualenv
|
||||
- virtualenv venv
|
||||
- make env
|
||||
- make lint
|
||||
- make coverage
|
||||
|
|
32
Makefile
32
Makefile
|
@ -1,2 +1,32 @@
|
|||
env:
|
||||
define run_migrations ## run migrations
|
||||
@ . ./venv/bin/activate && python manage.py makemigrations
|
||||
@ . ./venv/bin/activate && python manage.py migrate
|
||||
endef
|
||||
|
||||
default: ## Run app
|
||||
$(call run_migrations)
|
||||
. ./venv/bin/activate && python manage.py runserver
|
||||
|
||||
env: ## setup environment
|
||||
@-virtualenv venv
|
||||
. ./venv/bin/activate && pip install -r requirements.txt
|
||||
|
||||
coverage: ## Generate test coverage report
|
||||
. ./venv/bin/activate && coverage run manage.py test
|
||||
. ./venv/bin/activate && coverage report -m
|
||||
. ./venv/bin/activate && coverage html
|
||||
|
||||
freeze: ## Freeze python dependencies
|
||||
@. ./venv/bin/activate && pip freeze > requirements.txt
|
||||
|
||||
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 accounts sso
|
||||
|
||||
migrate: ## Run migrations
|
||||
$(call run_migrations)
|
||||
|
||||
test: ## Run tests
|
||||
@. ./venv/bin/activate && python manage.py test --parallel
|
||||
|
|
0
accounts/__init__.py
Normal file
0
accounts/__init__.py
Normal file
3
accounts/admin.py
Normal file
3
accounts/admin.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
6
accounts/apps.py
Normal file
6
accounts/apps.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AccountsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "accounts"
|
110
accounts/management/commands/create_oidc.py
Normal file
110
accounts/management/commands/create_oidc.py
Normal 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 redirection URI of relying party/client",
|
||||
)
|
||||
|
||||
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}")
|
0
accounts/migrations/__init__.py
Normal file
0
accounts/migrations/__init__.py
Normal file
3
accounts/models.py
Normal file
3
accounts/models.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
from django.db import models
|
||||
|
||||
# Create your models here.
|
104
accounts/tests.py
Normal file
104
accounts/tests.py
Normal 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
30
accounts/utils.py
Normal 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
3
accounts/views.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
24
docs/management-cmd.md
Normal file
24
docs/management-cmd.md
Normal file
|
@ -0,0 +1,24 @@
|
|||
## 1. Create OIDC application
|
||||
|
||||
### Pre-requisites:
|
||||
|
||||
1. A registered and active/email-verified user
|
||||
|
||||
### Command:
|
||||
|
||||
```bash
|
||||
python manage.py create_oidc <app-name> <username> <redirect_uri>
|
||||
```
|
||||
|
||||
### Example:
|
||||
|
||||
```bash
|
||||
$ python manage.py create_oidc my_application admin https://example.org/sso-redirect
|
||||
New application foo created successfully.
|
||||
client_id: U6qi6FIzj5AvbYJehEkf7yzVbvUyOAQsrTwspmsQ
|
||||
client_secret: OEZjyHT4KBjvpTWod0QIxX8uWmnq8EQi2nsMU5WfZgu94wrzRdHjptEJ57O5SSEWsvOATOFMASVpBqYtboisdyfdpAC43gcMnbbHsP4QRE7dv66e4rYzyIdI9LuBMoh1
|
||||
```
|
||||
|
||||
Configure [relying
|
||||
party/application](https://openid.net/specs/openid-connect-core-1_0.html#Terminology)
|
||||
to use `client_id` and `client_secret`
|
|
@ -1,4 +1,35 @@
|
|||
asgiref==3.5.2
|
||||
backports.zoneinfo==0.2.1
|
||||
astroid==2.11.7
|
||||
black==22.6.0
|
||||
certifi==2022.6.15
|
||||
cffi==1.15.1
|
||||
charset-normalizer==2.1.1
|
||||
click==8.1.3
|
||||
coverage==6.4.4
|
||||
cryptography==37.0.4
|
||||
Deprecated==1.2.13
|
||||
dill==0.3.5.1
|
||||
Django==4.1
|
||||
django-oauth-toolkit==2.1.0
|
||||
greenlet==1.1.2
|
||||
idna==3.3
|
||||
isort==5.10.1
|
||||
jedi==0.18.1
|
||||
jwcrypto==1.3.1
|
||||
lazy-object-proxy==1.7.1
|
||||
mccabe==0.7.0
|
||||
msgpack==1.0.4
|
||||
mypy-extensions==0.4.3
|
||||
oauthlib==3.2.0
|
||||
parso==0.8.3
|
||||
pathspec==0.9.0
|
||||
platformdirs==2.5.2
|
||||
pycparser==2.21
|
||||
pylint==2.14.5
|
||||
pynvim==0.4.3
|
||||
requests==2.28.1
|
||||
sqlparse==0.4.2
|
||||
tomli==2.0.1
|
||||
tomlkit==0.11.4
|
||||
urllib3==1.26.11
|
||||
wrapt==1.14.1
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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,52 +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',
|
||||
"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",
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -86,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",
|
||||
},
|
||||
]
|
||||
|
||||
|
@ -103,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
|
||||
|
||||
|
@ -115,9 +117,21 @@ 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/"
|
||||
|
||||
OAUTH2_PROVIDER = {
|
||||
"OIDC_ENABLED": True,
|
||||
"PKCE_REQUIRED": False,
|
||||
"SCOPES": {
|
||||
"openid": "OpenID Connect scope",
|
||||
},
|
||||
}
|
||||
|
|
|
@ -14,8 +14,9 @@ Including another URLconf
|
|||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from django.urls import path
|
||||
from django.urls import path, include
|
||||
|
||||
urlpatterns = [
|
||||
path('admin/', admin.site.urls),
|
||||
path("admin/", admin.site.urls),
|
||||
path("o/", include("oauth2_provider.urls", namespace="oauth2_provider")),
|
||||
]
|
||||
|
|
|
@ -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()
|
||||
|
|
Reference in a new issue