Skip to content

Commit

Permalink
Merge pull request #2358 from IFRCGo/feature/open-id
Browse files Browse the repository at this point in the history
django-oauth-toolkit setup
  • Loading branch information
tnagorra authored Jan 16, 2025
2 parents b03b6f1 + 74ae699 commit a70e7d8
Show file tree
Hide file tree
Showing 16 changed files with 892 additions and 98 deletions.
2 changes: 1 addition & 1 deletion .python-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3.11.10
3.11
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -289,3 +289,7 @@ To update GO countries and districts Mapbox tilesets, run the management command

## Import GEC codes
To import GEC codes along with country ids, run `python manage.py import-gec-code appeal_ingest_match.csv`. The CSV should have the columns `'GST_code', 'GST_name', 'GO ID', 'ISO'`

## SSO setup

For more info checkout [GO-SSO](./docs/go-sso.md)
42 changes: 42 additions & 0 deletions api/forms.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
from django import forms
from django.contrib.auth import authenticate
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _

from .logger import logger
from .models import Action, ActionOrg, ActionType


Expand All @@ -11,3 +15,41 @@ class ActionForm(forms.ModelForm):
class Meta:
model = Action
fields = "__all__"


class LoginForm(forms.Form):
email = forms.CharField(label=_("email"), required=True)
password = forms.CharField(
label=_("password"),
widget=forms.PasswordInput(),
required=True,
)

# FIXME: We need to refactor this code
def get_user(self, username, password):
if "ifrc" in password.lower() or "redcross" in password.lower():
logger.warning("User should be warned to use a stronger password.")

if username is None or password is None:
raise ValidationError("Should not happen. Frontend prevents login without username/password")

user = authenticate(username=username, password=password)
if user is None and User.objects.filter(email=username).count() > 1:
users = User.objects.filter(email=username, is_active=True)
# FIXME: Use users.exists()
if users:
# We get the first one if there are still multiple available is_active:
user = authenticate(username=users[0].username, password=password)

return user

def clean(self):
cleaned_data = super().clean()
email = cleaned_data.get("email")
password = cleaned_data.get("password")
user = self.get_user(email, password)
if not user:
raise ValidationError("Invalid credentials.")

cleaned_data["user"] = user
return cleaned_data
13 changes: 13 additions & 0 deletions api/management/commands/oauth_cleartokens.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from django.core import management
from django.core.management.base import BaseCommand
from sentry_sdk.crons import monitor

from main.sentry import SentryMonitor


class Command(BaseCommand):
help = "A wrapper for cleartokens command to track using sentry cron monitor. Feel free to use cleartokens"

@monitor(monitor_slug=SentryMonitor.OAUTH_CLEARTOKENS)
def handle(self, *args, **kwargs):
management.call_command("cleartokens")
127 changes: 127 additions & 0 deletions api/templates/oauth2_provider/base.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{% block title %}{% endblock title %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="">
<meta name="author" content="">

<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;600&display=swap" rel="stylesheet">
{% block css %}
{% endblock css %}

<style>
:root {
font-family: 'Poppins', sans-serif;
}
body, html {
margin: 0;
padding: 0;
}
h1, h2, h3, h4, h5, h6 {
margin: 0;
}
p {
margin: 0;
}
* { box-sizing: border-box }

body {
background-color: #f0f0f0;
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}

.container {
display: flex;
flex-direction: column;
gap: 1.5rem;
align-items: center;
justify-content: center;
animation: slide-up .5s .3s ease-in forwards;
opacity: 0;
width: 100%;
max-width: 30rem;
}

.block-center {
width: 100%;
background-color: #ffffff;
padding: 1.5rem;
border-radius: 0.5rem;
box-shadow: 0 5px 3px -2px rgba(0, 0, 0, 0.2);
}

@keyframes slide-up {
0% {
opacity: 0;
transform: translateY(10px);
}

100% {
opacity: 1;
transform: translateY(0);
}
}

.block-center form {
display: flex;
flex-direction: column;
gap: 1rem;
}

ul {
margin: 0;
}

button,
input[type='submit'] {
border: unset;
line-height: 1;
padding: 0.5rem 1rem;
border-radius: 1.5rem;
border: 1px solid rgba(0, 0, 0, 0.1);
cursor: pointer;
width: fit-content;
}

.btn-primary {
background-color: #e04656;
color: #fff;
border-color: #e04656;
}

.control-group {
display: flex;
flex-direction: row;
justify-content: flex-end;
}

.control-group .controls {
display: flex;
gap: 0.5rem;
}

#go-logo {
height: 3rem;
}
</style>
</head>

<body>

<div class="container">
<img id="go-logo" alt="IFRC GO" src="{% static 'images/logo/go-logo-2020-6cdc2b0c.svg' %}" />
{% block content %}
{% endblock content %}
</div>
</body>
</html>
71 changes: 71 additions & 0 deletions api/templates/oauth2_provider/sso-auth.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
{% extends "oauth2_provider/base.html" %}
{% block title %}
IFRC GO | SSO Login
{% endblock %}
{% block css %}
<style>
form {
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
}

form > div {
display: flex;
flex-direction: column;
}

form label {
text-transform: capitalize;
font-size: 0.875rem;
}

form input[type="password"],
form input[type="text"] {
border: unset;
background-color: rgba(0, 0, 0, 0.1);
padding: 0.5rem;
border-radius: 0.25rem;
}

.block-center {
display: flex;
flex-direction: column;
gap: 1rem;
}

</style>
{% endblock %}
{% block content %}
{% if request.user.is_authenticated %}
<div class="block-center">
<h2>GO SSO</h2>
<div>Hi {% firstof request.user.get_full_name request.user.username %}</div>
{% if request.user.is_staff %}
<a href="{% url "admin:index" %}">Admin panel</>
{% endif %}
<form method="post" action="{{ logout_url }}">
{% csrf_token %}
<div class="control-group">
<button class='btn-primary' type="submit">
Logout
</button>
</div>
</form>
</div>
{% else %}
<div class="block-center">
<h2>GO SSO Login</h2>
<form method="post">
{% csrf_token %}
{{ form.as_div }}
<div class="control-group">
<button class='btn-primary' type="submit">
Login
</button>
</div>
</form>
</div>
{% endif %}
{% endblock %}
70 changes: 69 additions & 1 deletion api/views.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,30 @@
import json
from datetime import datetime, timedelta
from urllib.parse import urlparse

from django.conf import settings
from django.contrib.auth import authenticate
from django.contrib.auth import authenticate, login, logout
from django.contrib.auth.models import User
from django.db.models import Case, Count, F, Q, Sum, When
from django.db.models.fields import IntegerField
from django.db.models.functions import TruncMonth, TruncYear
from django.http import HttpResponse, JsonResponse
from django.shortcuts import redirect
from django.template.loader import render_to_string
from django.urls import reverse
from django.utils import timezone
from django.utils.crypto import get_random_string
from django.utils.http import url_has_allowed_host_and_scheme
from django.views import View
from django.views.generic.edit import FormView
from drf_spectacular.utils import extend_schema, extend_schema_view
from haystack.query import SQ, SearchQuerySet
from rest_framework import authentication, permissions
from rest_framework.authtoken.models import Token
from rest_framework.response import Response
from rest_framework.views import APIView

from api.forms import LoginForm
from api.models import Country, District, Region
from api.serializers import (
AggregateByDtypeSerializer,
Expand Down Expand Up @@ -802,10 +808,12 @@ def get(self, request):
class GetAuthToken(APIView):
permission_classes = []

# FIXME: We need to refactor this block
def post(self, request):
username = request.data.get("username", None)
password = request.data.get("password", None)

# FIXME: Remove this
if "ifrc" in password.lower() or "redcross" in password.lower():
logger.warning("User should be warned to use a stronger password.")

Expand All @@ -816,6 +824,7 @@ def post(self, request):
user = authenticate(username=username, password=password)
if user is None and User.objects.filter(email=username).count() > 1:
users = User.objects.filter(email=username, is_active=True)
# FIXME: Use users.exists()
if users:
# We get the first one if there are still multiple available is_active:
user = authenticate(username=users[0].username, password=password)
Expand Down Expand Up @@ -994,3 +1003,62 @@ def get(self, request, *args, **kwargs):
class DummyExceptionError(View):
def get(self, request, *args, **kwargs):
raise Exception("Dev raised exception!")


class LoginFormView(FormView):
template_name = "oauth2_provider/sso-auth.html"
form_class = LoginForm

def is_safe_url(self, url):
# get_host already validates the given host, so no need to check it again
allowed_hosts = {self.request.get_host()} | set(settings.ALLOWED_HOSTS)

if "*" in allowed_hosts:
parsed_host = urlparse(url).netloc
allowed_host = {parsed_host} if parsed_host else None
return url_has_allowed_host_and_scheme(url, allowed_hosts=allowed_host)

return url_has_allowed_host_and_scheme(url, allowed_hosts=allowed_hosts)

def get_context_data(self, **kwargs):
context_data = super().get_context_data(**kwargs)
context_data["logout_url"] = reverse("go_logout")
return context_data

def form_valid(self, form):
user = form.cleaned_data["user"]

# Determining the client IP is not always straightforward:
clientIP = ""
# if 'REMOTE_ADDR' in request.META: clientIP += 'R' + request.META['REMOTE_ADDR']
# if 'HTTP_CLIENT_IP' in request.META: clientIP += 'C' + request.META['HTTP_CLIENT_IP']
# if 'HTTP_X_FORWARDED' in request.META: clientIP += 'x' + request.META['HTTP_X_FORWARDED']
# if 'HTTP_FORWARDED_FOR' in request.META: clientIP += 'F' + request.META['HTTP_FORWARDED_FOR']
# if 'HTTP_FORWARDED' in request.META: clientIP += 'f' + request.META['HTTP_FORWARDED']
if "HTTP_X_FORWARDED_FOR" in self.request.META:
clientIP += self.request.META["HTTP_X_FORWARDED_FOR"].split(",")[0]

logger.info(
"%s FROM %s: %s (%s) %s"
% (
user.username,
clientIP,
"ok" if user else "ERR",
self.request.META["HTTP_ACCEPT_LANGUAGE"] if "HTTP_ACCEPT_LANGUAGE" in self.request.META else "",
self.request.META["HTTP_USER_AGENT"] if "HTTP_USER_AGENT" in self.request.META else "",
)
)

login(self.request, user)

next_url = self.request.GET.get("next")
if next_url and self.is_safe_url(next_url):
return redirect(next_url)

return self.render_to_response(self.get_context_data(form=form))


def logout_user(request):
if request.method == "POST" and request.user.is_authenticated:
logout(request)
return redirect(reverse(settings.LOGIN_URL))
Loading

0 comments on commit a70e7d8

Please sign in to comment.