Skip to content
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -231,4 +231,4 @@ services:
logging:
options:
max-size: "20k"
max-file: "10"
max-file: "10"
Empty file.
6 changes: 6 additions & 0 deletions src/apps/oidc_configurations/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from django.contrib import admin
from .models import Auth_Organization

admin.site.register(Auth_Organization)

# Register your models here.
5 changes: 5 additions & 0 deletions src/apps/oidc_configurations/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from django.apps import AppConfig


class OidcConfigurationsConfig(AppConfig):
name = 'oidc_configurations'
29 changes: 29 additions & 0 deletions src/apps/oidc_configurations/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -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)),
],
),
]
Empty file.
14 changes: 14 additions & 0 deletions src/apps/oidc_configurations/models.py
Original file line number Diff line number Diff line change
@@ -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')
10 changes: 10 additions & 0 deletions src/apps/oidc_configurations/urls.py
Original file line number Diff line number Diff line change
@@ -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/<int:auth_organization_id>/', oidc_complete, name='oidc_complete'),
]
203 changes: 203 additions & 0 deletions src/apps/oidc_configurations/views.py
Original file line number Diff line number Diff line change
@@ -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
25 changes: 25 additions & 0 deletions src/apps/profiles/migrations/0013_auto_20240304_0616.py
Original file line number Diff line number Diff line change
@@ -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'),
),
]
5 changes: 5 additions & 0 deletions src/apps/profiles/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
When,
DecimalField,
)
from oidc_configurations.models import Auth_Organization

PROFILE_DATA_BLACKLIST = [
'password',
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions src/apps/profiles/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions src/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
'health',
'forums',
'announcements',
'oidc_configurations',
)
INSTALLED_APPS = THIRD_PARTY_APPS + OUR_APPS

Expand Down
16 changes: 16 additions & 0 deletions src/templates/oidc/oidc_complete.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{% extends 'base.html' %}
{% load static %}

{% block content %}
<div class="sixteen wide mobile six wide computer centered">
{% if error %}
<h3 class="ui centered header">OIDC Error</h3>
<div class="ui red message">
<h4>{{ error }}</h4>
{% if error_description %}
<p>{{ error_description }}</p>
{% endif %}
</div>
{% endif %}
</div>
{% endblock %}
Loading