From 9919e5d5adb33a525ebf0b79e8ecd26fddde5e7e Mon Sep 17 00:00:00 2001 From: Ivan Nikolskiy Date: Tue, 10 Jun 2025 02:57:33 +0200 Subject: [PATCH] Init --- LICENSE | 21 ++ MANIFEST.in | 5 + README.md | 184 ++++++++++++++++++ django_hopid/__init__.py | 23 +++ django_hopid/decorators.py | 76 ++++++++ django_hopid/settings.py | 35 ++++ .../django_hopid/hopid_login_button.html | 3 + django_hopid/templatetags/__init__.py | 23 +++ django_hopid/templatetags/hopid_tags.py | 43 ++++ django_hopid/urls.py | 9 + django_hopid/utils.py | 113 +++++++++++ django_hopid/validators.py | 56 ++++++ django_hopid/views.py | 13 ++ example/demoapp/demoapp/__init__.py | 23 +++ example/demoapp/demoapp/asgi.py | 31 +++ example/demoapp/demoapp/settings.py | 141 ++++++++++++++ example/demoapp/demoapp/templates/index.html | 63 ++++++ example/demoapp/demoapp/urls.py | 39 ++++ example/demoapp/demoapp/views.py | 28 +++ example/demoapp/demoapp/wsgi.py | 31 +++ example/demoapp/manage.py | 44 +++++ setup.py | 51 +++++ 22 files changed, 1055 insertions(+) create mode 100755 LICENSE create mode 100755 MANIFEST.in create mode 100755 README.md create mode 100755 django_hopid/__init__.py create mode 100755 django_hopid/decorators.py create mode 100755 django_hopid/settings.py create mode 100644 django_hopid/templates/django_hopid/hopid_login_button.html create mode 100644 django_hopid/templatetags/__init__.py create mode 100644 django_hopid/templatetags/hopid_tags.py create mode 100644 django_hopid/urls.py create mode 100644 django_hopid/utils.py create mode 100644 django_hopid/validators.py create mode 100644 django_hopid/views.py create mode 100755 example/demoapp/demoapp/__init__.py create mode 100755 example/demoapp/demoapp/asgi.py create mode 100755 example/demoapp/demoapp/settings.py create mode 100755 example/demoapp/demoapp/templates/index.html create mode 100755 example/demoapp/demoapp/urls.py create mode 100755 example/demoapp/demoapp/views.py create mode 100755 example/demoapp/demoapp/wsgi.py create mode 100755 example/demoapp/manage.py create mode 100755 setup.py diff --git a/LICENSE b/LICENSE new file mode 100755 index 0000000..d64a124 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Hopsenn + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100755 index 0000000..956d747 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,5 @@ +include README.md +include LICENSE +recursive-include django_hopid/static * +recursive-include django_hopid/templates * +recursive-include django_hopid/templatetags *.py \ No newline at end of file diff --git a/README.md b/README.md new file mode 100755 index 0000000..d0bfefb --- /dev/null +++ b/README.md @@ -0,0 +1,184 @@ +# HopID Client for Django + +**django-hopid** is a lightweight Django library that simplifies OpenID Connect (OIDC) authentication with an authorization server powered by `django-oauth-toolkit`. It offers decorators, utility functions, URLs, and template tags to streamline secure login integration. + +--- + +## ๐Ÿ“š Table of Contents + +* [โœ… Features](#โœ…-features) +* [๐Ÿ› ๏ธ Installation](#๐Ÿ› ๏ธ-installation) +* [๐Ÿš€ Quick Start](#๐Ÿš€-quick-start) + + * [1. Configure settings](#1-configure-settings) + * [2. Include URLs](#2-include-urls) + * [3. Use the callback decorator](#3-use-the-callback-decorator) + * [4. Use the login button tag](#4-use-the-login-button-tag) +* [โš™๏ธ Advanced Usage](#โš™๏ธ-advanced-usage) + + * [Custom callback response handling](#custom-callback-response-handling) + * [Use login URL in views](#use-login-url-in-views) + * [Logout integration](#logout-integration) +* [๐Ÿ“„ License](#๐Ÿ“„-license) + +--- + +## โœ… Features + +* ๐Ÿงท OpenID Connect Authorization Code flow with PKCE and nonce support +* ๐Ÿ” Secure token exchange and ID token verification (including issuer, nonce, and access token hash checks) +* ๐Ÿค Seamless user login or creation using the access token +* ๐ŸŒˆ Template tag for styled login button +* โš–๏ธ Built-in URL routes for callback and logout +* โœจ Minimal configuration, no third-party dependencies + +--- + +## ๐Ÿ› ๏ธ Installation + +```bash +pip install django-hopid +``` + +Add to `INSTALLED_APPS` (for templates): + +```python +INSTALLED_APPS = [ + ..., + 'django_hopid', +] +``` + +Ensure `request` is in template context: + +```python +TEMPLATES = [ + { + 'OPTIONS': { + 'context_processors': [ + ..., + 'django.template.context_processors.request', + ], + }, + }, +] +``` + +--- + +## ๐Ÿš€ Quick Start + +### 1. Configure settings + +Add the following to your `settings.py`: + +```python +HOPID_URL = "https://auth.example.com" +HOPID_CLIENT_ID = "your-client-id" +HOPID_CLIENT_SECRET = "your-client-secret" +HOPID_CLIENT_URI = "https://yourapp.example.com" +``` + +### 2. Include URLs + +In your app's `urls.py`, include HopID routes: + +```python +from django.urls import path, include + +urlpatterns = [ + path("auth/", include("django_hopid.urls")), +] +``` + +This provides: + +* `/auth/callback/` for OIDC callback +* `/auth/logout/` for logout + +### 3. Use the callback decorator + +Create your callback view: + +```python +from django.shortcuts import render +from django_hopid.decorators import hopid_callback + +@hopid_callback() +def hopid_callback_view(request, *args, **kwargs): + user = kwargs.get("user") + return render(request, "home.html", {"user": user}) +``` + +### 4. Use the login button tag + +In any template: + +```django +{% load hopid_tags %} +{% hopid_login_button %} +``` + +This renders a styled login button based on your template: + +```html + + Login with HopID + +``` + +--- + +## โš™๏ธ Advanced Usage + +### Custom callback response handling + +Pass a custom `response=` to the decorator to override error handling: + +```python +@hopid_callback(response=my_error_view) +def hopid_callback_view(request, user=None, error=None): + if error: + return render(request, "error.html", {"error": error}) + return render(request, "home.html", {"user": user}) +``` + +### Use login URL in views + +If you want to build your own login redirect view: + +```python +from django.shortcuts import redirect +from django_hopid.utils import get_hopid_login_url + +def login_view(request): + return redirect(get_hopid_login_url(request)) +``` + +### Logout integration + +Logout is handled via: + +```python +from django.contrib.auth import logout as django_logout +from django.shortcuts import redirect +from django_hopid.utils import get_hopid_logout_url + +def logout_view(request): + django_logout(request) + return redirect(get_hopid_logout_url(request)) +``` + +Or use the built-in route `/auth/logout/`. + +--- + +## ๐Ÿ“„ License + +This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. + +--- + +## ๐Ÿค Contributing + +Contributions are welcome! Open issues or pull requests on [Git](https://git.hopsenn.com/hopsenn/django-hopid). \ No newline at end of file diff --git a/django_hopid/__init__.py b/django_hopid/__init__.py new file mode 100755 index 0000000..8699746 --- /dev/null +++ b/django_hopid/__init__.py @@ -0,0 +1,23 @@ +""" +MIT License + +Copyright (c) 2025 Hopsenn + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" \ No newline at end of file diff --git a/django_hopid/decorators.py b/django_hopid/decorators.py new file mode 100755 index 0000000..3d16305 --- /dev/null +++ b/django_hopid/decorators.py @@ -0,0 +1,76 @@ +""" +MIT License + +Copyright (c) 2025 Hopsenn + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +from functools import wraps +from django.contrib.auth import login as auth_login + +from . import settings +from .validators import verify_id_token +from .utils import get_jwt_tokens, get_user_from_token + + +def hopid_callback(response=None): + def decorator(view_func): + @wraps(view_func) + def _wrapped_view(request, *args, **kwargs): + def fail(reason): + if callable(response): + return response(request, reason) + + return view_func(request, *args, **kwargs, error=reason) + + code = request.GET.get('code') + if not code: + return fail("No code returned") + + tokens = get_jwt_tokens(code, request.session.pop('pkce_verifier', '')) + error = tokens.get('error', '') + + if error: + return fail(error) + + access_token = tokens.get('access_token') + id_token = tokens.get('id_token') + + if not access_token or not id_token: + return fail("No ID token returned") + + claims = verify_id_token(id_token, access_token) + if not claims: + return fail("Invalid ID token") + + expected_nonce = request.session.pop('oidc_nonce', None) + + if not claims or claims.get("nonce") != expected_nonce: + return fail("Invalid or missing nonce") + + profile = get_user_from_token(access_token) + if not profile: + return fail("User info request failed") + + auth_login(request, profile) + return view_func(request, *args, **kwargs) + + return _wrapped_view + return decorator diff --git a/django_hopid/settings.py b/django_hopid/settings.py new file mode 100755 index 0000000..012ec78 --- /dev/null +++ b/django_hopid/settings.py @@ -0,0 +1,35 @@ +""" +MIT License + +Copyright (c) 2025 Hopsenn + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +from django.conf import settings + +def get(key, default=None): + return getattr(settings, key, default) + +HOPID_URL = get('HOPID_URL', 'https://id.hopsenn.com') +HOPID_CLIENT_ID = get('HOPID_CLIENT_ID', '') +HOPID_CLIENT_SECRET = get('HOPID_CLIENT_SECRET', '') +HOPID_CLIENT_URI = get('HOPID_CLIENT_URI', '') +HOPID_CLIENT_LOGOUT_URI = get('HOPID_CLIENT_LOGOUT_URI', '') +HOPID_CLIENT_CALLBACK_URI = get('HOPID_CLIENT_CALLBACK_URI', '') diff --git a/django_hopid/templates/django_hopid/hopid_login_button.html b/django_hopid/templates/django_hopid/hopid_login_button.html new file mode 100644 index 0000000..a474985 --- /dev/null +++ b/django_hopid/templates/django_hopid/hopid_login_button.html @@ -0,0 +1,3 @@ + + Login with HopID + \ No newline at end of file diff --git a/django_hopid/templatetags/__init__.py b/django_hopid/templatetags/__init__.py new file mode 100644 index 0000000..8699746 --- /dev/null +++ b/django_hopid/templatetags/__init__.py @@ -0,0 +1,23 @@ +""" +MIT License + +Copyright (c) 2025 Hopsenn + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" \ No newline at end of file diff --git a/django_hopid/templatetags/hopid_tags.py b/django_hopid/templatetags/hopid_tags.py new file mode 100644 index 0000000..95846aa --- /dev/null +++ b/django_hopid/templatetags/hopid_tags.py @@ -0,0 +1,43 @@ +""" +MIT License + +Copyright (c) 2025 Hopsenn + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +from django import template +from django_hopid.utils import get_hopid_login_url +from django.template.loader import render_to_string + +register = template.Library() + + +@register.simple_tag(takes_context=True) +def hopid_login_url(context): + request = context['request'] + return get_hopid_login_url(request) + + +@register.simple_tag(takes_context=True) +def hopid_login_button(context): + request = context['request'] + url = get_hopid_login_url(request) + + return render_to_string('django_hopid/hopid_login_button.html', {'url': url}) diff --git a/django_hopid/urls.py b/django_hopid/urls.py new file mode 100644 index 0000000..f8b1614 --- /dev/null +++ b/django_hopid/urls.py @@ -0,0 +1,9 @@ +from django.urls import path +from django_hopid.views import hopid_logout_view, hopid_callback_view + +app_name = "django_hopid" + +urlpatterns = [ + path("callback/", hopid_callback_view, name="callback"), + path("logout/", hopid_logout_view, name="logout"), +] \ No newline at end of file diff --git a/django_hopid/utils.py b/django_hopid/utils.py new file mode 100644 index 0000000..9165102 --- /dev/null +++ b/django_hopid/utils.py @@ -0,0 +1,113 @@ +""" +MIT License + +Copyright (c) 2025 Hopsenn + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +import hashlib +import base64 +import secrets +import requests + +from . import settings +from urllib.parse import urlencode +from django.contrib.auth import get_user_model + + +def get_hopid_logout_url(): + next_url = settings.HOPID_CLIENT_LOGOUT_URI or settings.HOPID_CLIENT_URI + query = urlencode({"next": next_url}) + return f"{settings.HOPID_URL}/users/logout/?{query}" + + +def get_user_from_token(access_token): + userinfo_url = f"{settings.HOPID_URL}/users/userinfo/" + headers = {'Authorization': f'Bearer {access_token}'} + + try: + userinfo_response = requests.get(userinfo_url, headers=headers, timeout=5) + except requests.RequestException: + return None + + if userinfo_response.status_code != 200: + return None + + userinfo = userinfo_response.json() + email = userinfo.get('email') + username = userinfo.get('username') + + if not email or not username: + return None + + profile, _ = get_user_model().objects.get_or_create(email=email, defaults={'username': username.lower()}) + return profile + + +def get_jwt_tokens(code, code_verifier): + token_url = f"{settings.HOPID_URL}/o/token/" + token_data = { + 'grant_type': 'authorization_code', + 'code': code, + 'redirect_uri': settings.HOPID_CLIENT_CALLBACK_URI or settings.HOPID_CLIENT_URI + '/id/callback/', + 'client_id': settings.HOPID_CLIENT_ID, + 'client_secret': settings.HOPID_CLIENT_SECRET, + 'code_verifier': code_verifier, + } + + try: + token_response = requests.post(token_url, data=token_data, timeout=5) + except requests.RequestException: + return {"error": "Token request failed"} + + if token_response.status_code != 200: + return {"error": "Token exchange failed"} + + tokens = token_response.json() + return tokens + + +def generate_pkce_pair(): + verifier = secrets.token_urlsafe(64) + challenge = base64.urlsafe_b64encode( + hashlib.sha256(verifier.encode()).digest() + ).rstrip(b'=').decode() + return verifier, challenge + + +def get_hopid_login_url(request): + nonce = secrets.token_urlsafe(32) + verifier, challenge = generate_pkce_pair() + + request.session['pkce_verifier'] = verifier + request.session['oidc_nonce'] = nonce + + base = f"{settings.HOPID_URL}/o/authorize/" + + params = { + "client_id": settings.HOPID_CLIENT_ID, + "response_type": "code", + "scope": "openid profile email", + "redirect_uri": settings.HOPID_CLIENT_CALLBACK_URI or settings.HOPID_CLIENT_URI + '/id/callback/', + "nonce": nonce, + "code_challenge": challenge, + "code_challenge_method": "S256" + } + return f"{base}?{urlencode(params)}" diff --git a/django_hopid/validators.py b/django_hopid/validators.py new file mode 100644 index 0000000..025be6c --- /dev/null +++ b/django_hopid/validators.py @@ -0,0 +1,56 @@ +""" +MIT License + +Copyright (c) 2025 Hopsenn + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +import requests + +from jose import jwt, JWTError +from . import settings + + +def verify_id_token(id_token, access_token): + jwks = requests.get(f"{settings.HOPID_URL}/.well-known/jwks.json").json() + + try: + # Get unverified header to find 'kid' + unverified_header = jwt.get_unverified_header(id_token) + kid = unverified_header['kid'] + + # Find the matching key + key = next(k for k in jwks['keys'] if k['kid'] == kid) + issuer = settings.HOPID_URL.rstrip('/') + + # Decode & verify + claims = jwt.decode( + id_token, + key=key, + algorithms=['RS256'], + audience=settings.HOPID_CLIENT_ID, + issuer=issuer, + access_token=access_token + ) + + return claims # Validated claims + + except JWTError as e: + print("Invalid id_token:", e) diff --git a/django_hopid/views.py b/django_hopid/views.py new file mode 100644 index 0000000..f267fcc --- /dev/null +++ b/django_hopid/views.py @@ -0,0 +1,13 @@ +from django.shortcuts import redirect +from django.contrib.auth import logout as django_logout + +from .utils import get_hopid_logout_url +from .decorators import hopid_callback + +@hopid_callback() +def hopid_callback_view(request, *args, **kwargs): + return redirect("/") + +def hopid_logout_view(request): + django_logout(request) + return redirect(get_hopid_logout_url()) diff --git a/example/demoapp/demoapp/__init__.py b/example/demoapp/demoapp/__init__.py new file mode 100755 index 0000000..8699746 --- /dev/null +++ b/example/demoapp/demoapp/__init__.py @@ -0,0 +1,23 @@ +""" +MIT License + +Copyright (c) 2025 Hopsenn + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" \ No newline at end of file diff --git a/example/demoapp/demoapp/asgi.py b/example/demoapp/demoapp/asgi.py new file mode 100755 index 0000000..0c7bd2a --- /dev/null +++ b/example/demoapp/demoapp/asgi.py @@ -0,0 +1,31 @@ +""" +MIT License + +Copyright (c) 2025 Hopsenn + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'demoapp.settings') + +application = get_asgi_application() diff --git a/example/demoapp/demoapp/settings.py b/example/demoapp/demoapp/settings.py new file mode 100755 index 0000000..7207064 --- /dev/null +++ b/example/demoapp/demoapp/settings.py @@ -0,0 +1,141 @@ +""" +MIT License + +Copyright (c) 2025 Hopsenn + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +from pathlib import Path + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 'django-insecure-jv__th8%4!0ite+vhv%c@(q$1=n8xwg(46@k3zbz7^oeq-+)p&' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +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_hopid' +] + +HOPID_URL = 'http://localhost:8000' +HOPID_CLIENT_ID = 'XUFHPoHC9bKF8Vf93srxqfmyjtXCSwx3KsQdsNDP' +HOPID_CLIENT_SECRET = 'ipzAGxVsdh9nt5rmYXHcNpkIw8XzYOd6WyVApWraMD0ehclLzN7F94kEcUYIweSVsrWgWMOu0Qp8oqqWUpPIhqOxTyIH6qFmaE14KBwWSInx1H3tlVQWLdrSpjmJhEru' +HOPID_CLIENT_URI = 'http://localhost:8001' + +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', +] + +ROOT_URLCONF = 'demoapp.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [BASE_DIR / 'demoapp'], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'demoapp.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/5.2/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'db.sqlite3', + } +} + + +# Password validation +# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/5.2/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/5.2/howto/static-files/ + +STATIC_URL = 'static/' +STATIC_ROOT = BASE_DIR / 'staticfiles' + +# Default primary key field type +# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' diff --git a/example/demoapp/demoapp/templates/index.html b/example/demoapp/demoapp/templates/index.html new file mode 100755 index 0000000..d461176 --- /dev/null +++ b/example/demoapp/demoapp/templates/index.html @@ -0,0 +1,63 @@ +{% load static hopid_tags %} + + + + + + HopID Demo + + + + +

HopID Demo

+ + {% if request.user.is_authenticated %} +

Hello, {{ request.user.username }}!

+ Logout + {% else %} + Login with HopID + {% endif %} + + diff --git a/example/demoapp/demoapp/urls.py b/example/demoapp/demoapp/urls.py new file mode 100755 index 0000000..d032a42 --- /dev/null +++ b/example/demoapp/demoapp/urls.py @@ -0,0 +1,39 @@ +""" +MIT License + +Copyright (c) 2025 Hopsenn + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +from django.contrib import admin +from django.urls import path, include +from django.conf import settings +from django.conf.urls.static import static + +from . import views + +urlpatterns = [ + path('admin/', admin.site.urls), + path('', views.index, name='index'), + path("id/", include("django_hopid.urls")), +] + +if settings.DEBUG: + urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) \ No newline at end of file diff --git a/example/demoapp/demoapp/views.py b/example/demoapp/demoapp/views.py new file mode 100755 index 0000000..ad1d388 --- /dev/null +++ b/example/demoapp/demoapp/views.py @@ -0,0 +1,28 @@ +""" +MIT License + +Copyright (c) 2025 Hopsenn + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +from django.shortcuts import render + +def index(request): + return render(request, "templates/index.html") diff --git a/example/demoapp/demoapp/wsgi.py b/example/demoapp/demoapp/wsgi.py new file mode 100755 index 0000000..c622109 --- /dev/null +++ b/example/demoapp/demoapp/wsgi.py @@ -0,0 +1,31 @@ +""" +MIT License + +Copyright (c) 2025 Hopsenn + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'demoapp.settings') + +application = get_wsgi_application() diff --git a/example/demoapp/manage.py b/example/demoapp/manage.py new file mode 100755 index 0000000..a3c2886 --- /dev/null +++ b/example/demoapp/manage.py @@ -0,0 +1,44 @@ +""" +MIT License + +Copyright (c) 2025 Hopsenn + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'demoapp.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/setup.py b/setup.py new file mode 100755 index 0000000..cec7b27 --- /dev/null +++ b/setup.py @@ -0,0 +1,51 @@ +""" +MIT License + +Copyright (c) 2025 Hopsenn + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +from setuptools import setup, find_packages + +setup( + name='django-hopid', + version='1.0.0', + description='', + author='Hopsenn', + author_email='ivan.nikolskiy@hopsenn.com', + url='https://git.hopsenn.com/hopsenn/django-hopid', + packages=find_packages(), + include_package_data=True, + install_requires=[ + 'requests>=2.20', + 'Django>=3.2', + 'python-jose', + 'requests' + ], + classifiers=[ + 'Development Status :: 4 - Beta', + 'Framework :: Django', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: MIT License', + 'Operating System :: OS Independent', + 'Programming Language :: Python :: 3', + ], + python_requires='>=3.6', +)