diff --git a/docker-compose.yml b/docker-compose.yml index 0b5c2c6ee..08e7a5c84 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -231,4 +231,4 @@ services: logging: options: max-size: "20k" - max-file: "10" + max-file: "10" \ No newline at end of file diff --git a/src/apps/oidc_configurations/__init__.py b/src/apps/oidc_configurations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/apps/oidc_configurations/admin.py b/src/apps/oidc_configurations/admin.py new file mode 100644 index 000000000..5ea6e683f --- /dev/null +++ b/src/apps/oidc_configurations/admin.py @@ -0,0 +1,6 @@ +from django.contrib import admin +from .models import Auth_Organization + +admin.site.register(Auth_Organization) + +# Register your models here. diff --git a/src/apps/oidc_configurations/apps.py b/src/apps/oidc_configurations/apps.py new file mode 100644 index 000000000..3d757062b --- /dev/null +++ b/src/apps/oidc_configurations/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class OidcConfigurationsConfig(AppConfig): + name = 'oidc_configurations' diff --git a/src/apps/oidc_configurations/migrations/0001_initial.py b/src/apps/oidc_configurations/migrations/0001_initial.py new file mode 100644 index 000000000..085e64983 --- /dev/null +++ b/src/apps/oidc_configurations/migrations/0001_initial.py @@ -0,0 +1,29 @@ +# Generated by Django 2.2.17 on 2024-03-04 06:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Auth_Organization', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('client_id', models.CharField(max_length=255)), + ('client_secret', models.CharField(max_length=255)), + ('authorization_url', models.CharField(max_length=255)), + ('token_url', models.CharField(max_length=255)), + ('user_info_url', models.CharField(max_length=255)), + ('redirect_url', models.CharField(max_length=255)), + ('button_bg_color', models.CharField(default='#2C3E4C', max_length=20)), + ('button_text_color', models.CharField(default='#FFFFFF', max_length=20)), + ], + ), + ] diff --git a/src/apps/oidc_configurations/migrations/__init__.py b/src/apps/oidc_configurations/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/apps/oidc_configurations/models.py b/src/apps/oidc_configurations/models.py new file mode 100644 index 000000000..9e2b0c66c --- /dev/null +++ b/src/apps/oidc_configurations/models.py @@ -0,0 +1,14 @@ +# oidc_configurations/models.py +from django.db import models + + +class Auth_Organization(models.Model): + name = models.CharField(max_length=255) + client_id = models.CharField(max_length=255) + client_secret = models.CharField(max_length=255) + authorization_url = models.CharField(max_length=255) + token_url = models.CharField(max_length=255) + user_info_url = models.CharField(max_length=255) + redirect_url = models.CharField(max_length=255) + button_bg_color = models.CharField(max_length=20, default='#2C3E4C') + button_text_color = models.CharField(max_length=20, default='#FFFFFF') diff --git a/src/apps/oidc_configurations/urls.py b/src/apps/oidc_configurations/urls.py new file mode 100644 index 000000000..7bfae4f99 --- /dev/null +++ b/src/apps/oidc_configurations/urls.py @@ -0,0 +1,10 @@ +# oidc_configurations/urls.py +from django.urls import path +from .views import organization_oidc_login, oidc_complete + +app_name = 'oidc_configurations' + +urlpatterns = [ + path('organization_oidc_login/', organization_oidc_login, name='organization_oidc_login'), + path('complete//', oidc_complete, name='oidc_complete'), +] diff --git a/src/apps/oidc_configurations/views.py b/src/apps/oidc_configurations/views.py new file mode 100644 index 000000000..6b04be8ff --- /dev/null +++ b/src/apps/oidc_configurations/views.py @@ -0,0 +1,203 @@ +# oidc_configurations/views.py +import base64 +import requests +from django.shortcuts import render, redirect, get_object_or_404 +from .models import Auth_Organization +from django.contrib.auth import get_user_model, login +import re + +User = get_user_model() + +BACKEND = 'django.contrib.auth.backends.ModelBackend' + + +def organization_oidc_login(request): + # Check if this is a post request and it contains organization_oauth2_login + if request.method == 'POST' and 'organization_oidc_login' in request.POST: + # Get auth organization id from the request + auth_organization_id = request.POST.get('organization_oidc_login') + + # Get auth organization using its id + organization = get_object_or_404(Auth_Organization, pk=auth_organization_id) + + if organization: + # Create a redirect url consisiting of + # - authorization_url + # - client_id + # - response_type + # - redirect_uri + oidc_auth_url = ( + f"{organization.authorization_url}?" + f"client_id={organization.client_id}&" + "response_type=code&" + "scope=openid profile email&" + f"redirect_uri={organization.redirect_url}" + ) + + # Redirect the user to the OIDC provider's authorization URL + return redirect(oidc_auth_url) + + # Handle other cases or render a different template if needed + return render(request, 'registration/login.html') + + +def oidc_complete(request, auth_organization_id): + + # create empty context + context = {} + + # Get error or authorization code from the query string + error = request.GET.get('error', None) + error_description = request.GET.get('error_description', None) + authorization_code = request.GET.get('code', None) + + if error: + context["error"] = error + + if error_description: + context["error_description"] = error_description + + # Token exhange process + if authorization_code: + + try: + # STEP 1: Get auth organization using its id + organization = get_object_or_404(Auth_Organization, pk=auth_organization_id) + + if organization: + + # STEP 2: Get access token + access_token, token_error = get_access_token(organization, authorization_code) + + if token_error: + context["error"] = token_error + else: + # STEP 3: Get user info + user_info, user_info_error = get_user_info(organization, access_token) + if user_info_error: + context["error"] = user_info_error + else: + + # get email and nickname (username) of the user + user_email = user_info.get("email", None) + user_nickname = user_info.get("nickname", None) + if user_email: + # get user with this email + user = get_user_by_email(user_email) + # STEP 4: Check if user exists and user is created using oidc and oidc orgnaization matches this one + if user: + login(request, user, backend=BACKEND) + # Redirect the user home page + return redirect('pages:home') + else: + return register_and_authenticate_user(request, user_email, user_nickname, organization) + + else: + context["error"] = "Unable to extract email from user info! Please contact platform" + else: + context["error"] = "Invalid Organization ID!" + except Exception as e: + context["error"] = f"{e}" + + return render(request, 'oidc/oidc_complete.html', context) + + +def get_access_token(organization, authorization_code): + + token_url = organization.token_url + client_id = organization.client_id + client_secret = organization.client_secret + redirect_url = organization.redirect_url + + auth_header = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode("utf-8") + headers = { + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": f"Basic {auth_header}", + } + data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": redirect_url, + } + + try: + response = requests.request("POST", token_url, data=data, headers=headers) + response.raise_for_status() # Raise an HTTPError for bad responses (4xx or 5xx) + token_data = response.json() + access_token = token_data.get('access_token') + return access_token, None + except requests.exceptions.RequestException as e: + print(f"Error during token request: {e}") + return None, e + except Exception as e: + print(f"Error parsing token response: {e}") + return None, e + + +def get_user_info(organization, access_token): + + user_info_url = organization.user_info_url + + headers = { + 'Authorization': f'Bearer {access_token}', + } + + response = requests.get(user_info_url, headers=headers) + + try: + user_info = response.json() + return user_info, None + except Exception as e: + return None, e + + +def register_and_authenticate_user(request, user_email, user_nickname, organization): + + if not user_nickname: + username = re.sub(r'[^a-zA-Z0-9]', '', user_email.split('@')[0]) + else: + username = user_nickname + + # Ensure the username is unique + username = create_unique_username(username) + + # Create a new user + user = User.objects.create( + username=username, + email=user_email, + is_created_using_oidc=True, + oidc_organization=organization, + ) + + if user: + # login user + login(request, user, backend=BACKEND) + # Redirect to the home page + return redirect('pages:home') + + else: + # Handle authentication failure i.e. go back to login + return redirect('accounts:login') + + +def create_unique_username(username): + # Check if the username already exists + if User.objects.filter(username=username).exists(): + # If the username already exists, modify it to make it unique + suffix = 1 + new_username = f"{username}_{suffix}" + while User.objects.filter(username=new_username).exists(): + suffix += 1 + new_username = f"{username}_{suffix}" + return new_username + else: + # If the username doesn't exist, use it as is + return username + + +def get_user_by_email(email): + try: + user = User.objects.get(email=email) + return user + except User.DoesNotExist: + return None diff --git a/src/apps/profiles/migrations/0013_auto_20240304_0616.py b/src/apps/profiles/migrations/0013_auto_20240304_0616.py new file mode 100644 index 000000000..121ca477c --- /dev/null +++ b/src/apps/profiles/migrations/0013_auto_20240304_0616.py @@ -0,0 +1,25 @@ +# Generated by Django 2.2.17 on 2024-03-04 06:16 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('oidc_configurations', '0001_initial'), + ('profiles', '0012_user_quota'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='is_created_using_oidc', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='user', + name='oidc_organization', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='authorized_users', to='oidc_configurations.Auth_Organization'), + ), + ] diff --git a/src/apps/profiles/models.py b/src/apps/profiles/models.py index 518230f92..b150e54e4 100644 --- a/src/apps/profiles/models.py +++ b/src/apps/profiles/models.py @@ -16,6 +16,7 @@ When, DecimalField, ) +from oidc_configurations.models import Auth_Organization PROFILE_DATA_BLACKLIST = [ 'password', @@ -72,6 +73,10 @@ class User(ChaHubSaveMixin, AbstractBaseUser, PermissionsMixin): is_staff = models.BooleanField(default=False) quota = models.BigIntegerField(default=settings.DEFAULT_USER_QUOTA, null=False) + # Fields for OIDC authentication + is_created_using_oidc = models.BooleanField(default=False) + oidc_organization = models.ForeignKey(Auth_Organization, null=True, blank=True, on_delete=models.SET_NULL, related_name="authorized_users") + # Notifications organizer_direct_message_updates = models.BooleanField(default=True) allow_forum_notifications = models.BooleanField(default=True) diff --git a/src/apps/profiles/views.py b/src/apps/profiles/views.py index 3b6b22169..a6a21cb64 100644 --- a/src/apps/profiles/views.py +++ b/src/apps/profiles/views.py @@ -21,6 +21,7 @@ UserNotificationSerializer from .forms import SignUpForm, LoginForm from .models import User, Organization, Membership +from oidc_configurations.models import Auth_Organization from .tokens import account_activation_token @@ -178,6 +179,11 @@ def log_in(request): else: context['form'] = form + # Fetch auth_organizations from the database + auth_organizations = Auth_Organization.objects.all() + if auth_organizations: + context['auth_organizations'] = auth_organizations + if not context.get('form'): context['form'] = LoginForm() return render(request, 'registration/login.html', context) diff --git a/src/settings/base.py b/src/settings/base.py index b8d133a05..27e76045a 100644 --- a/src/settings/base.py +++ b/src/settings/base.py @@ -60,6 +60,7 @@ 'health', 'forums', 'announcements', + 'oidc_configurations', ) INSTALLED_APPS = THIRD_PARTY_APPS + OUR_APPS diff --git a/src/templates/oidc/oidc_complete.html b/src/templates/oidc/oidc_complete.html new file mode 100644 index 000000000..293d52823 --- /dev/null +++ b/src/templates/oidc/oidc_complete.html @@ -0,0 +1,16 @@ +{% extends 'base.html' %} +{% load static %} + +{% block content %} +
+ {% if error %} +

OIDC Error

+
+

{{ error }}

+ {% if error_description %} +

{{ error_description }}

+ {% endif %} +
+ {% endif %} +
+{% endblock %} \ No newline at end of file diff --git a/src/templates/registration/login.html b/src/templates/registration/login.html index 2f1372819..b2c29a8d1 100644 --- a/src/templates/registration/login.html +++ b/src/templates/registration/login.html @@ -62,5 +62,31 @@

{% endif %} + + + {% if auth_organizations %} +
+

Organization Login

+
+ {% csrf_token %} +
+ + +
+ {% for organization in auth_organizations %} + + {% endfor %} +
+ +
+ {% endif %} {% endblock %} diff --git a/src/urls.py b/src/urls.py index ea610fd46..a74ff4b5b 100644 --- a/src/urls.py +++ b/src/urls.py @@ -30,6 +30,8 @@ path('accounts/', include('profiles.urls_accounts')), path('admin/', admin.site.urls), path('social/', include('social_django.urls', namespace='social')), + path('oidc/', include('oidc_configurations.urls')), + ]