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

SSO: Auth0 integration #355

Draft
wants to merge 4 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion config/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
"wagtail.contrib.settings.context_processors.settings",
"etna.core.context_processors.globals",
"etna.core.context_processors.feature_flags",
],
},
Expand All @@ -125,14 +126,21 @@
"allauth.account.auth_backends.AuthenticationBackend",
]

WSGI_APPLICATION = "config.wsgi.application"

# django-allauth configuration
ACCOUNT_AUTHENTICATION_METHOD = "email"
ACCOUNT_EMAIL_REQUIRED = True
ACCOUNT_LOGOUT_ON_GET = False # Bypass logout confirmation form
ACCOUNT_USERNAME_REQUIRED = False # Register using email only
ACCOUNT_SESSION_REMEMBER = False # True|False disables "Remember me?" checkbox"
AUTH_URLS = "allauth.urls"
LOGIN_URL = "/accounts/login"
LOGIN_REDIRECT_URL = "/"
LOGOUT_URL = "/accounts/logout"
LOGOUT_REDIRECT_URL = "/"
MY_ACCOUNT_URL = os.getenv("MY_ACCOUNT_URL")
REGISTER_URL = os.getenv("REGISTER_URL")
WAGTAIL_FRONTEND_LOGIN_URL = LOGIN_URL
# View access control
IMAGE_VIEWER_REQUIRE_LOGIN = strtobool(os.getenv("IMAGE_VIEWER_REQUIRE_LOGIN", "True"))
Expand All @@ -144,7 +152,22 @@
ACCOUNT_ADAPTER = "etna.users.adapters.NoSelfSignupAccountAdapter"
ACCOUNT_FORMS = {"login": "etna.users.forms.EtnaLoginForm"}

WSGI_APPLICATION = "config.wsgi.application"
# Auth0 configuration
AUTH0_DOMAIN = os.getenv("AUTH0_DOMAIN")
AUTH0_CLIENT_ID = os.getenv("AUTH0_CLIENT_ID")
AUTH0_CLIENT_SECRET = os.getenv("AUTH0_CLIENT_SECRET")
AUTH0_EMAIL_VERIFICATION_REQUIRED = strtobool(
os.getenv("AUTH0_EMAIL_VERIFICATION_REQUIRED", "True")
)
AUTH0_VERIFY_EMAIL_URL = os.getenv("AUTH0_VERIFY_EMAIL_URL")
if AUTH0_CLIENT_SECRET:
# When configured, use the 'auth0' app instead of 'allauth' for
# authentication-related things
INSTALLED_APPS.insert(0, "etna.auth0")
AUTH_URLS = "etna.auth0.urls"
# We still want regular django username/password login to work too,
# hence 'appending' here
AUTHENTICATION_BACKENDS.append("etna.auth0.auth_backend.Auth0Backend")

# Logging
# https://docs.djangoproject.com/en/3.2/topics/logging/
Expand Down
2 changes: 1 addition & 1 deletion config/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ def trigger_error(request):
private_urls = [
path("django-admin/", admin.site.urls),
path("admin/", include(wagtailadmin_urls)),
path("accounts/", include("allauth.urls")),
path("documents/", include(wagtaildocs_urls)),
path("accounts/", include(settings.AUTH_URLS)),
]

if settings.SENTRY_DEBUG_URL_ENABLED:
Expand Down
Empty file added etna/auth0/__init__.py
Empty file.
13 changes: 13 additions & 0 deletions etna/auth0/auth_backend.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from django.contrib.auth.backends import ModelBackend


class Auth0Backend(ModelBackend):
"""
A backend that overrides the `authenticate()` method to prevent succesfull
use with anything other than ``views.authorize()``, which specifies this
as the ``backend`` for users when calling `django.contrib.auth.login()`
(after successful authentication with Auth0).
"""

def authenticate(self, request, **kwargs):
return None
15 changes: 15 additions & 0 deletions etna/auth0/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from django.urls import path

from . import views

"""
NOTE: The intention here is to mimic allauth in terms of naming / paths, so
that you can always use django.urls.reverse() or {% url 'url_name' %} tag to
link to the key views, regardless of whether AUTH0 is configured/enabled.
"""
urlpatterns = [
path("login/", views.login, name="account_login"),
path("logout/", views.logout, name="account_logout"),
path("signup/", views.register, name="account_signup"),
path("authorize/", views.authorize, name="account_authorize"),
]
ababic marked this conversation as resolved.
Show resolved Hide resolved
134 changes: 134 additions & 0 deletions etna/auth0/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
from urllib.parse import quote_plus, urlencode, urlparse

from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth import login as auth_login
from django.contrib.auth import logout as auth_logout
from django.http import HttpResponseForbidden, HttpResponseRedirect
from django.shortcuts import redirect
from django.urls import reverse
from django.utils import timezone

from authlib.integrations.django_client import OAuth

from etna.users.models import IDPProfile

User = get_user_model()

PROVIDER_NAME = "auth0"

oauth = OAuth()
oauth.register(
PROVIDER_NAME,
client_id=settings.AUTH0_CLIENT_ID,
client_secret=settings.AUTH0_CLIENT_SECRET,
client_kwargs={
"scope": "openid profile email",
},
server_metadata_url=f"https://{settings.AUTH0_DOMAIN}/.well-known/openid-configuration",
)


def login(request):
callback_url = reverse("account_authorize")
if next := request.GET.get("next"):
request.session["auth_success_url"] = next
return oauth.auth0.authorize_redirect(
request, request.build_absolute_uri(callback_url)
)


def register(request):
callback_url = reverse("account_authorize")
if next := request.GET.get("next"):
request.session["auth_success_url"] = next
return oauth.auth0.authorize_redirect(
request,
request.build_absolute_uri(callback_url),
screen_hint="signup",
prompt="login",
)


def authorize(request):
if success_url := request.session.get("auth_success_url"):
parsed = urlparse(success_url)
if parsed.netloc and parsed.netloc != request.META.get("HTTP_HOST"):
success_url = "/"
else:
success_url = "/"

token = oauth.auth0.authorize_access_token(request)
user_info = token["userinfo"]
user_id = user_info.get("user_id") or user_info.get("sub")
now = timezone.now()

try:
# First, try to find a user with a matching profile
profile = IDPProfile.objects.select_related("user").get(
provider_name=PROVIDER_NAME, provider_user_id=user_id
)
except IDPProfile.DoesNotExist:
# If no Django user was found, create a new one with a unique username
candidate_username = user_info["nickname"][:150]
username = candidate_username
i = 1
while User.objects.filter(username=username).exists():
username = f"{candidate_username[:148]}{i}"
i += 1

user = User(
username=username,
email=user_info["email"],
first_name=user_info.get("given_name", ""),
last_name=user_info.get("family_name", ""),
)
user.set_unusable_password()
user.save()

# Finally, create the IDDProfile object to link the user to this login
IDPProfile.objects.create(
user=user,
provider_name=PROVIDER_NAME,
provider_user_id=user_id,
last_login=now,
)
else:
# Update the 'last_login' timestamp for the existing profile
profile.last_login = now
profile.save(update_fields=["last_login"])

# Update local user to reflect any changes in auth0
user = profile.user
user.__dict__.update(
email=user_info["email"],
first_name=user_info.get("given_name", ""),
last_name=user_info.get("family_name", ""),
)

# If email verification is required, reject access to non-verified emails
if settings.AUTH0_EMAIL_VERIFICATION_REQUIRED and not user_info["email_verified"]:
url = settings.AUTH0_VERIFY_EMAIL_URL
if not url:
return HttpResponseForbidden(
"This service can only be used by users with a verified email address."
)
return redirect(url)

auth_login(request, user, backend="etna.auth0.auth_backend.Auth0Backend")
return HttpResponseRedirect(success_url)


def logout(request):
success_url = settings.LOGOUT_REDIRECT_URL
auth_logout(request)
return redirect(
f"https://{settings.AUTH0_DOMAIN}/v2/logout?"
+ urlencode(
{
"returnTo": request.build_absolute_uri(success_url),
"client_id": settings.AUTH0_CLIENT_ID,
},
quote_via=quote_plus,
),
)
10 changes: 10 additions & 0 deletions etna/core/context_processors.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
from django.conf import settings


def globals(request):
"""
Adds common setting (and potentially other values) to the context.
"""
return {
"MY_ACCOUNT_URL": settings.MY_ACCOUNT_URL,
"REGISTER_URL": settings.REGISTER_URL,
}


def feature_flags(request):
"""
Makes any settings with the "FEATURE_" prefix available template contexts,
Expand Down
50 changes: 50 additions & 0 deletions etna/users/migrations/0002_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Generated by Django 3.2.13 on 2022-06-03 14:03

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

initial = True

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("users", "0001_create_beta_testers_group"),
]

operations = [
migrations.CreateModel(
name="IDPProfile",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("provider_name", models.CharField(db_index=True, max_length=100)),
("provider_user_id", models.CharField(db_index=True, max_length=36)),
("created", models.DateTimeField(auto_now_add=True)),
("last_login", models.DateTimeField(null=True)),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="idp_profiles",
to=settings.AUTH_USER_MODEL,
),
),
],
),
migrations.AddConstraint(
model_name="idpprofile",
constraint=models.UniqueConstraint(
fields=("provider_name", "provider_user_id"), name="idp_unique"
),
),
]
18 changes: 18 additions & 0 deletions etna/users/migrations/0003_alter_idpprofile_provider_user_id.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 3.2.13 on 2022-06-20 12:12

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("users", "0002_initial"),
]

operations = [
migrations.AlterField(
model_name="idpprofile",
name="provider_user_id",
field=models.CharField(db_index=True, max_length=100),
),
]
19 changes: 19 additions & 0 deletions etna/users/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from django.conf import settings
from django.db import models


class IDPProfile(models.Model):
user = models.ForeignKey(
settings.AUTH_USER_MODEL, related_name="idp_profiles", on_delete=models.CASCADE
)
provider_name = models.CharField(max_length=100, db_index=True)
provider_user_id = models.CharField(max_length=100, db_index=True)
created = models.DateTimeField(auto_now_add=True)
last_login = models.DateTimeField(null=True)

class Meta:
constraints = [
models.UniqueConstraint(
fields=["provider_name", "provider_user_id"], name="idp_unique"
),
]
Loading