Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

OIDC PR#2 --- organization login using OIDC #1309

Merged
merged 11 commits into from
Apr 11, 2024
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