You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@airavata.apache.org by ma...@apache.org on 2021/06/25 18:26:05 UTC
[airavata-django-portal] branch
AIRAVATA-3319-handle-missing-name-and-email-attributes-from-cilo updated
(125fd07 -> 77a3413)
This is an automated email from the ASF dual-hosted git repository.
machristie pushed a change to branch AIRAVATA-3319-handle-missing-name-and-email-attributes-from-cilo
in repository https://gitbox.apache.org/repos/asf/airavata-django-portal.git.
from 125fd07 AIRAVATA-3455 user profile editor form validation
new 96bc6bf AIRAVATA-3468 Check if profile is complete and redirect to profile editor if not
new a99970f AIRAVATA-3468 Store IDP userinfo
new 77a3413 AIRAVATA-3468 configuration of URLs for retrieving external IDP userinfo
The 3 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
Summary of changes:
django_airavata/apps/auth/backends.py | 46 ++++++++++++++++-
django_airavata/apps/auth/middleware.py | 26 ++++++++--
.../auth/migrations/0009_auto_20210625_1725.py | 41 ++++++++++++++++
django_airavata/apps/auth/models.py | 57 ++++++++++++++++++++--
django_airavata/apps/auth/signals.py | 17 ++++---
.../js/components/UserProfileEditor.vue | 18 ++++++-
django_airavata/apps/auth/urls.py | 2 +-
django_airavata/apps/auth/views.py | 6 ++-
django_airavata/settings.py | 1 +
9 files changed, 194 insertions(+), 20 deletions(-)
create mode 100644 django_airavata/apps/auth/migrations/0009_auto_20210625_1725.py
[airavata-django-portal] 02/03: AIRAVATA-3468 Store IDP userinfo
Posted by ma...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
machristie pushed a commit to branch AIRAVATA-3319-handle-missing-name-and-email-attributes-from-cilo
in repository https://gitbox.apache.org/repos/asf/airavata-django-portal.git
commit a99970faa473696bdce85fefbb1f85da0d679b31
Author: Marcus Christie <ma...@apache.org>
AuthorDate: Fri Jun 25 13:39:14 2021 -0400
AIRAVATA-3468 Store IDP userinfo
This is mostly for debugging purposes, to see what is and isn't included in CILogon userinfo.
---
django_airavata/apps/auth/backends.py | 34 +++++++++++++++++-
.../auth/migrations/0009_auto_20210625_1725.py | 41 ++++++++++++++++++++++
django_airavata/apps/auth/models.py | 17 +++++++++
django_airavata/apps/auth/views.py | 6 ++--
4 files changed, 95 insertions(+), 3 deletions(-)
diff --git a/django_airavata/apps/auth/backends.py b/django_airavata/apps/auth/backends.py
index 24f3143..cc0df6b 100644
--- a/django_airavata/apps/auth/backends.py
+++ b/django_airavata/apps/auth/backends.py
@@ -29,7 +29,8 @@ class KeycloakBackend(object):
request=None,
username=None,
password=None,
- refresh_token=None):
+ refresh_token=None,
+ idp_alias=None):
try:
user = None
access_token = None
@@ -71,6 +72,9 @@ class KeycloakBackend(object):
request)
self._process_token(request, token)
user = self._process_userinfo(request, userinfo)
+ if idp_alias is not None:
+ self._store_idp_userinfo(user, token, idp_alias)
+ # TODO: if idp_alias, add idp userinfo too
access_token = token['access_token']
# authz_token_middleware has already run, so must manually add
# the `request.authz_token` attribute
@@ -260,3 +264,31 @@ class KeycloakBackend(object):
user_profile.save()
user_profile.userinfo_set.create(claim='sub', value=sub)
return user
+
+ def _store_idp_userinfo(self, user, token, idp_alias):
+ try:
+ access_token = token['access_token']
+ logger.debug(f"access_token={access_token} for idp_alias={idp_alias}")
+ # fetch the idp's token
+ headers = {'Authorization': f'Bearer {access_token}'}
+ # For the following to work, in Keycloak the IDP should have 'Store
+ # Tokens' and 'Stored Tokens Readable' enabled and the user needs
+ # the broker/read-token role
+ r = requests.get(f"https://iamdev.scigap.org/auth/realms/seagrid/broker/{idp_alias}/token", headers=headers)
+ idp_token = r.json()
+ idp_headers = {'Authorization': f"Bearer {idp_token['access_token']}"}
+ r = requests.get("https://cilogon.org/oauth2/userinfo", headers=idp_headers)
+ userinfo = r.json()
+ logger.debug(f"userinfo={userinfo}")
+
+ # Save the idp user info claims
+ user_profile = user.user_profile
+ for (claim, value) in userinfo.items():
+ if user_profile.idp_userinfo.filter(idp_alias=idp_alias, claim=claim).exists():
+ userinfo_claim = user_profile.idp_userinfo.get(idp_alias=idp_alias, claim=claim)
+ userinfo_claim.value = value
+ userinfo_claim.save()
+ else:
+ user_profile.idp_userinfo.create(idp_alias=idp_alias, claim=claim, value=value)
+ except Exception:
+ logger.exception(f"Failed to store IDP userinfo for {user.username} from IDP {idp_alias}")
diff --git a/django_airavata/apps/auth/migrations/0009_auto_20210625_1725.py b/django_airavata/apps/auth/migrations/0009_auto_20210625_1725.py
new file mode 100644
index 0000000..1d38199
--- /dev/null
+++ b/django_airavata/apps/auth/migrations/0009_auto_20210625_1725.py
@@ -0,0 +1,41 @@
+# Generated by Django 2.2.23 on 2021-06-25 17:25
+
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('django_airavata_auth', '0008_auto_20210422_1838'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='userinfo',
+ name='created_date',
+ field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
+ preserve_default=False,
+ ),
+ migrations.AddField(
+ model_name='userinfo',
+ name='updated_date',
+ field=models.DateTimeField(auto_now=True),
+ ),
+ migrations.CreateModel(
+ name='IDPUserInfo',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('idp_alias', models.CharField(max_length=64)),
+ ('claim', models.CharField(max_length=64)),
+ ('value', models.CharField(max_length=255)),
+ ('created_date', models.DateTimeField(auto_now_add=True)),
+ ('updated_date', models.DateTimeField(auto_now=True)),
+ ('user_profile', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='idp_userinfo', to='django_airavata_auth.UserProfile')),
+ ],
+ options={
+ 'unique_together': {('user_profile', 'claim', 'idp_alias')},
+ },
+ ),
+ ]
diff --git a/django_airavata/apps/auth/models.py b/django_airavata/apps/auth/models.py
index 86ab451..a7480ff 100644
--- a/django_airavata/apps/auth/models.py
+++ b/django_airavata/apps/auth/models.py
@@ -99,6 +99,8 @@ class UserInfo(models.Model):
claim = models.CharField(max_length=64)
value = models.CharField(max_length=255)
user_profile = models.ForeignKey(UserProfile, on_delete=models.CASCADE)
+ created_date = models.DateTimeField(auto_now_add=True)
+ updated_date = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ['user_profile', 'claim']
@@ -107,6 +109,21 @@ class UserInfo(models.Model):
return f"{self.claim}={self.value}"
+class IDPUserInfo(models.Model):
+ idp_alias = models.CharField(max_length=64)
+ claim = models.CharField(max_length=64)
+ value = models.CharField(max_length=255)
+ user_profile = models.ForeignKey(UserProfile, on_delete=models.CASCADE, related_name="idp_userinfo")
+ created_date = models.DateTimeField(auto_now_add=True)
+ updated_date = models.DateTimeField(auto_now=True)
+
+ class Meta:
+ unique_together = ['user_profile', 'claim', 'idp_alias']
+
+ def __str__(self):
+ return f"{self.idp_alias}: {self.claim}={self.value}"
+
+
class PendingEmailChange(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
email_address = models.EmailField()
diff --git a/django_airavata/apps/auth/views.py b/django_airavata/apps/auth/views.py
index fb30761..9e1d2d4 100644
--- a/django_airavata/apps/auth/views.py
+++ b/django_airavata/apps/auth/views.py
@@ -3,6 +3,7 @@ import time
from datetime import datetime, timedelta, timezone
from urllib.parse import quote, urlencode
+import requests
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import authenticate, get_user_model, login, logout
@@ -138,7 +139,9 @@ def start_logout(request):
def callback(request):
try:
login_desktop = request.GET.get('login_desktop', "false") == "true"
- user = authenticate(request=request)
+ idp_alias = request.GET.get('idp_alias')
+ user = authenticate(request=request, idp_alias=idp_alias)
+
if user is not None:
login(request, user)
if login_desktop:
@@ -153,7 +156,6 @@ def callback(request):
messages.error(
request,
"Failed to process OAuth2 callback: {}".format(str(err)))
- idp_alias = request.GET.get('idp_alias')
if login_desktop:
return _create_login_desktop_failed_response(
request, idp_alias=idp_alias)
[airavata-django-portal] 03/03: AIRAVATA-3468 configuration of URLs
for retrieving external IDP userinfo
Posted by ma...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
machristie pushed a commit to branch AIRAVATA-3319-handle-missing-name-and-email-attributes-from-cilo
in repository https://gitbox.apache.org/repos/asf/airavata-django-portal.git
commit 77a34135def6c43215687bb8e0a37830ba70b9a0
Author: Marcus Christie <ma...@apache.org>
AuthorDate: Fri Jun 25 13:50:16 2021 -0400
AIRAVATA-3468 configuration of URLs for retrieving external IDP userinfo
---
django_airavata/apps/auth/backends.py | 15 +++++++++++++--
1 file changed, 13 insertions(+), 2 deletions(-)
diff --git a/django_airavata/apps/auth/backends.py b/django_airavata/apps/auth/backends.py
index cc0df6b..1c534ce 100644
--- a/django_airavata/apps/auth/backends.py
+++ b/django_airavata/apps/auth/backends.py
@@ -267,6 +267,17 @@ class KeycloakBackend(object):
def _store_idp_userinfo(self, user, token, idp_alias):
try:
+ idp_token_url = None
+ userinfo_url = None
+ for auth_option in settings.AUTHENTICATION_OPTIONS['external']:
+ if auth_option['idp_alias'] == idp_alias:
+ idp_token_url = auth_option.get('idp_token_url')
+ userinfo_url = auth_option.get('userinfo_url')
+ break
+ if idp_token_url is None or userinfo_url is None:
+ logger.debug(f"idp_token_url and/or userinfo_url not set for {idp_alias} "
+ "in AUTHENTICATION_OPTIONS, skipping retrieval of external IDP userinfo")
+ return
access_token = token['access_token']
logger.debug(f"access_token={access_token} for idp_alias={idp_alias}")
# fetch the idp's token
@@ -274,10 +285,10 @@ class KeycloakBackend(object):
# For the following to work, in Keycloak the IDP should have 'Store
# Tokens' and 'Stored Tokens Readable' enabled and the user needs
# the broker/read-token role
- r = requests.get(f"https://iamdev.scigap.org/auth/realms/seagrid/broker/{idp_alias}/token", headers=headers)
+ r = requests.get(idp_token_url, headers=headers)
idp_token = r.json()
idp_headers = {'Authorization': f"Bearer {idp_token['access_token']}"}
- r = requests.get("https://cilogon.org/oauth2/userinfo", headers=idp_headers)
+ r = requests.get(userinfo_url, headers=idp_headers)
userinfo = r.json()
logger.debug(f"userinfo={userinfo}")
[airavata-django-portal] 01/03: AIRAVATA-3468 Check if profile is
complete and redirect to profile editor if not
Posted by ma...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
machristie pushed a commit to branch AIRAVATA-3319-handle-missing-name-and-email-attributes-from-cilo
in repository https://gitbox.apache.org/repos/asf/airavata-django-portal.git
commit 96bc6bf3bf27ce096d8dc384a65e68c66eca9ac0
Author: Marcus Christie <ma...@apache.org>
AuthorDate: Thu Jun 24 11:49:28 2021 -0400
AIRAVATA-3468 Check if profile is complete and redirect to profile editor if not
---
django_airavata/apps/auth/backends.py | 1 +
django_airavata/apps/auth/middleware.py | 26 ++++++++++++--
django_airavata/apps/auth/models.py | 40 +++++++++++++++++++---
django_airavata/apps/auth/signals.py | 17 +++++----
.../js/components/UserProfileEditor.vue | 18 ++++++++--
django_airavata/apps/auth/urls.py | 2 +-
django_airavata/settings.py | 1 +
7 files changed, 88 insertions(+), 17 deletions(-)
diff --git a/django_airavata/apps/auth/backends.py b/django_airavata/apps/auth/backends.py
index 349d4d2..24f3143 100644
--- a/django_airavata/apps/auth/backends.py
+++ b/django_airavata/apps/auth/backends.py
@@ -220,6 +220,7 @@ class KeycloakBackend(object):
# Update User model fields
user = user_profile.user
+ user.username = username
user.email = email
user.first_name = first_name
user.last_name = last_name
diff --git a/django_airavata/apps/auth/middleware.py b/django_airavata/apps/auth/middleware.py
index eb8a722..7921405 100644
--- a/django_airavata/apps/auth/middleware.py
+++ b/django_airavata/apps/auth/middleware.py
@@ -4,8 +4,10 @@ import logging
from django.conf import settings
from django.contrib.auth import logout
+from django.shortcuts import redirect
from . import utils
+from django.urls import reverse
log = logging.getLogger(__name__)
@@ -33,7 +35,10 @@ def gateway_groups_middleware(get_response):
"""Add 'is_gateway_admin' and 'is_read_only_gateway_admin' to request."""
def middleware(request):
- if not request.user.is_authenticated or not request.authz_token:
+ request.is_gateway_admin = False
+ request.is_read_only_gateway_admin = False
+
+ if not request.user.is_authenticated or not request.authz_token or not request.user.user_profile.is_complete:
return get_response(request)
try:
@@ -66,8 +71,23 @@ def gateway_groups_middleware(get_response):
except Exception as e:
log.warning("Failed to set is_gateway_admin, "
"is_read_only_gateway_admin for user", exc_info=e)
- request.is_gateway_admin = False
- request.is_read_only_gateway_admin = False
return get_response(request)
return middleware
+
+
+def user_profile_completeness_check(get_response):
+ """Check if user profile is complete and if not, redirect to user profile editor."""
+ def middleware(request):
+
+ if not request.user.is_authenticated:
+ return get_response(request)
+
+ if (not request.user.user_profile.is_complete and
+ request.path != reverse('django_airavata_auth:user_profile') and
+ request.path != reverse('django_airavata_auth:logout') and
+ request.META['HTTP_ACCEPT'] != 'application/json'):
+ return redirect('django_airavata_auth:user_profile')
+ else:
+ return get_response(request)
+ return middleware
diff --git a/django_airavata/apps/auth/models.py b/django_airavata/apps/auth/models.py
index c8c0da3..86ab451 100644
--- a/django_airavata/apps/auth/models.py
+++ b/django_airavata/apps/auth/models.py
@@ -3,6 +3,9 @@ import uuid
from django.conf import settings
from django.db import models
+from . import forms
+from django.core.exceptions import ValidationError
+
VERIFY_EMAIL_TEMPLATE = 1
NEW_USER_EMAIL_TEMPLATE = 2
PASSWORD_RESET_EMAIL_TEMPLATE = 3
@@ -58,11 +61,38 @@ class UserProfile(models.Model):
@property
def is_complete(self):
- # TODO: implement this to check if there are any missing fields on the
- # User model (email, first_name, last_name) or if the username was is
- # invalid (for example if defaulted to the IdP's 'sub' claim) or if
- # there are any extra profile fields that are not valid
- return False
+ return (self.is_username_valid and
+ self.is_first_name_valid and
+ self.is_last_name_valid and
+ self.is_email_valid)
+
+ @property
+ def is_username_valid(self):
+ # use forms.USERNAME_VALIDATOR with an exception when the username is
+ # equal to the email
+ try:
+ forms.USERNAME_VALIDATOR(self.user.username)
+ validates = True
+ except ValidationError:
+ validates = False
+ return (validates or (self.is_email_valid and self.user.email == self.user.username))
+
+ @property
+ def is_first_name_valid(self):
+ return self.is_non_empty(self.user.first_name)
+
+ @property
+ def is_last_name_valid(self):
+ return self.is_non_empty(self.user.last_name)
+
+ @property
+ def is_email_valid(self):
+ # Only checking for non-empty only; assumption is that email is verified
+ # before it is set or updated
+ return self.is_non_empty(self.user.email)
+
+ def is_non_empty(self, value: str):
+ return value is not None and value.strip() != ""
class UserInfo(models.Model):
diff --git a/django_airavata/apps/auth/signals.py b/django_airavata/apps/auth/signals.py
index dfc76cd..7cf1652 100644
--- a/django_airavata/apps/auth/signals.py
+++ b/django_airavata/apps/auth/signals.py
@@ -43,11 +43,16 @@ def initialize_user_profile(sender, request, user, **kwargs):
if not user_profile_client_pool.doesUserExist(authz_token,
user.username,
settings.GATEWAY_ID):
- user_profile_client_pool.initializeUserProfile(authz_token)
- log.info("initialized user profile for {}".format(user.username))
- # Since user profile created, inform admins of new user
- utils.send_new_user_email(
- request, user.username, user.email, user.first_name, user.last_name)
- log.info("sent new user email for user {}".format(user.username))
+ if user.user_profile.is_complete:
+ user_profile_client_pool.initializeUserProfile(authz_token)
+ log.info("initialized user profile for {}".format(user.username))
+ # Since user profile created, inform admins of new user
+ utils.send_new_user_email(
+ request, user.username, user.email, user.first_name, user.last_name)
+ log.info("sent new user email for user {}".format(user.username))
+ else:
+ log.info(f"user profile not complete for {user.username}, "
+ "skipping initializing Airavata user profile")
+
else:
log.warning(f"Logged in user {user.username} has no access token")
diff --git a/django_airavata/apps/auth/static/django_airavata_auth/js/components/UserProfileEditor.vue b/django_airavata/apps/auth/static/django_airavata_auth/js/components/UserProfileEditor.vue
index 506114c..b6b31fe 100644
--- a/django_airavata/apps/auth/static/django_airavata_auth/js/components/UserProfileEditor.vue
+++ b/django_airavata/apps/auth/static/django_airavata_auth/js/components/UserProfileEditor.vue
@@ -1,7 +1,15 @@
<template>
<b-card>
<b-form-group label="Username">
- <b-form-input disabled :value="user.username" />
+ <b-form-input
+ v-model="$v.user.username.$model"
+ @keydown.native.enter="save"
+ :state="validateState($v.user.username)"
+ />
+ <b-form-invalid-feedback v-if="!$v.user.username.emailOrMatchesRegex">
+ Username can only contain lowercase letters, numbers, underscores and
+ hyphens OR it can be the same as the email address.
+ </b-form-invalid-feedback>
</b-form-group>
<b-form-group label="First Name">
<b-form-input
@@ -46,7 +54,7 @@
import { models } from "django-airavata-api";
import { errors } from "django-airavata-common-ui";
import { validationMixin } from "vuelidate";
-import { email, required } from "vuelidate/lib/validators";
+import { email, helpers, or, required, sameAs } from "vuelidate/lib/validators";
export default {
name: "user-profile-editor",
@@ -63,8 +71,14 @@ export default {
};
},
validations() {
+ const usernameRegex = helpers.regex("username", /^[a-z0-9_-]+$/);
+ const emailOrMatchesRegex = or(usernameRegex, sameAs('email'));
return {
user: {
+ username: {
+ required,
+ emailOrMatchesRegex,
+ },
first_name: {
required,
},
diff --git a/django_airavata/apps/auth/urls.py b/django_airavata/apps/auth/urls.py
index 887ed74..b9a4d3b 100644
--- a/django_airavata/apps/auth/urls.py
+++ b/django_airavata/apps/auth/urls.py
@@ -5,7 +5,7 @@ from rest_framework import routers
from . import views
router = routers.DefaultRouter()
-router.register(r'users', views.UserViewSet, base_name='user')
+router.register(r'users', views.UserViewSet, basename='user')
app_name = 'django_airavata_auth'
urlpatterns = [
url(r'^', include(router.urls)),
diff --git a/django_airavata/settings.py b/django_airavata/settings.py
index 3ab8b1e..d2ce38f 100644
--- a/django_airavata/settings.py
+++ b/django_airavata/settings.py
@@ -130,6 +130,7 @@ MIDDLEWARE = [
# Wagtail related middleware
'wagtail.core.middleware.SiteMiddleware',
'wagtail.contrib.redirects.middleware.RedirectMiddleware',
+ 'django_airavata.apps.auth.middleware.user_profile_completeness_check',
]
ROOT_URLCONF = 'django_airavata.urls'