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'