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

ConnectID: Messaging Consent and Key #35212

Draft
wants to merge 12 commits into
base: master
Choose a base branch
from
22 changes: 21 additions & 1 deletion corehq/apps/domain/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from django.contrib.auth import authenticate, get_user_model
from django.contrib.auth.models import User
from django.db.models import Q
from django.http import HttpResponse
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden
from django.views.decorators.debug import sensitive_variables

from no_exceptions.exceptions import Http400
Expand Down Expand Up @@ -169,7 +169,9 @@
return real_decorator




def formplayer_auth(view):

Check failure on line 174 in corehq/apps/domain/auth.py

View workflow job for this annotation

GitHub Actions / Flake8

corehq/apps/domain/auth.py#L174

Too many blank lines (4) (E303)
return validate_request_hmac('FORMPLAYER_INTERNAL_AUTH_KEY')(view)


Expand Down Expand Up @@ -404,3 +406,21 @@
return False

return couch_user.is_member_of(project) or (couch_user.is_superuser and not project.restrict_superusers)


def connectid_token_auth(view_func):
@wraps(view_func)
def _inner(request, domain, *args, **kwargs):
auth_header = request.META.get("HTTP_AUTHORIZATION")
if not auth_header:
return HttpResponseForbidden()
_, token = auth_header.split(" ")
if not token:
return HttpResponseBadRequest("ConnectID Token Required")
username = get_connectid_userinfo(token)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might want to DRY this with ConnectIDAuthBackend

if username is None:
return HttpResponseForbidden()
link = ConnectIDUserLink.objects.get(connectid_username=username, domain=request.domain)
request.user = link.commcare_user
return view_func(request, domain, *args, **kwargs)
return _inner
1 change: 1 addition & 0 deletions corehq/apps/domain/deletion.py
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,7 @@ def _delete_demo_user_restores(domain_name):
ModelDeletion('userreports', 'InvalidUCRData', 'domain'),
ModelDeletion('userreports', 'UCRExpression', 'domain'),
ModelDeletion('users', 'ConnectIDUserLink', 'domain'),
ModelDeletion('users', 'ConnectIDMessagingKey', 'domain'),
ModelDeletion('users', 'DomainRequest', 'domain'),
ModelDeletion('users', 'DeactivateMobileWorkerTrigger', 'domain'),
ModelDeletion('users', 'Invitation', 'domain'),
Expand Down
1 change: 1 addition & 0 deletions corehq/apps/dump_reload/sql/dump.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@
FilteredModelIteratorBuilder('linked_domain.DomainLinkHistory', SimpleFilter('link__linked_domain')),
FilteredModelIteratorBuilder('user_importer.UserUploadRecord', SimpleFilter('domain')),
FilteredModelIteratorBuilder('users.ConnectIDUserLink', SimpleFilter('domain')),
FilteredModelIteratorBuilder('users.ConnectIDMessagingKey', SimpleFilter('domain')),
FilteredModelIteratorBuilder('users.DeactivateMobileWorkerTrigger', SimpleFilter('domain')),
FilteredModelIteratorBuilder('users.DomainRequest', SimpleFilter('domain')),
FilteredModelIteratorBuilder('users.Invitation', SimpleFilter('domain')),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Generated by Django 4.2.15 on 2024-10-18 06:35

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


class Migration(migrations.Migration):

dependencies = [
("users", "0072_remove_invitation_supply_point"),
]

operations = [
migrations.AddField(
model_name="connectiduserlink",
name="channel_id",
field=models.CharField(blank=True, null=True),
),
migrations.AddField(
model_name="connectiduserlink",
name="messaging_consent",
field=models.BooleanField(default=False),
),
migrations.CreateModel(
name="ConnectIDMessagingKey",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("domain", models.TextField()),
("key", models.CharField(blank=True, max_length=44, null=True)),
("created_on", models.DateTimeField(auto_now_add=True)),
("active", models.BooleanField(default=True)),
(
"connectid_user_link",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="users.connectiduserlink",
),
),
],
),
]
10 changes: 10 additions & 0 deletions corehq/apps/users/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3282,6 +3282,16 @@ class ConnectIDUserLink(models.Model):
connectid_username = models.TextField()
commcare_user = models.ForeignKey(User, related_name='connectid_user', on_delete=models.CASCADE)
domain = models.TextField()
messaging_consent = models.BooleanField(default=False)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should have a channel_id as well, since that is how incoming requests will be able to show which channel they go to.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added. 7d02e73

channel_id = models.CharField(null=True, blank=True)

class Meta:
unique_together = ('domain', 'commcare_user')


class ConnectIDMessagingKey(models.Model):
domain = models.TextField()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is necessary since the user_link has a domain field already

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should have an active boolean as well, so we rotate out keys

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added the domain here to support the domain deletion test. It was complaining before. I will add the active boolean here as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added. 7d02e73

connectid_user_link = models.ForeignKey(ConnectIDUserLink, on_delete=models.CASCADE)
key = models.CharField(max_length=44, null=True, blank=True)
created_on = models.DateTimeField(auto_now_add=True)
active = models.BooleanField(default=True)
12 changes: 12 additions & 0 deletions corehq/apps/users/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
UploadCommCareUsers,
UserUploadStatusView,
activate_commcare_user,
connectid_messaging_key,
count_commcare_users,
count_web_users,
deactivate_commcare_user,
Expand All @@ -71,6 +72,7 @@
reset_demo_user_restore,
restore_commcare_user,
toggle_demo_mode,
update_connectid_messaging_consent,
update_user_groups,
user_download_job_poll,
CommCareUserConfirmAccountView,
Expand Down Expand Up @@ -258,6 +260,16 @@
link_connectid_user,
name='link_connectid_user'
),
url(
r'^commcare/connectid_messaging_key/$',
connectid_messaging_key,
name='connectid_messaging_key',
),
url(
r'^commcare/update_connectid_messaging_consent/$',
update_connectid_messaging_consent,
name='update_connectid_messaging_consent',
),
] + [
url(r'^groups/$', GroupsListView.as_view(), name=GroupsListView.urlname),
url(r'^groups/(?P<group_id>[ \w-]+)/$', EditGroupMembersView.as_view(), name=EditGroupMembersView.urlname),
Expand Down
28 changes: 26 additions & 2 deletions corehq/apps/users/views/mobile/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
HttpResponseRedirect,
)
from django.http.response import HttpResponseServerError, JsonResponse
from django.shortcuts import redirect, render
from django.shortcuts import get_object_or_404, redirect, render
from django.template.loader import render_to_string
from django.urls import reverse
from django.utils.decorators import method_decorator
Expand All @@ -30,6 +30,7 @@
from memoized import memoized

from casexml.apps.phone.models import SyncLogSQL
from corehq.util.hmac_request import validate_request_hmac
from couchexport.models import Format
from couchexport.writers import Excel2007ExportWriter
from dimagi.utils.web import json_response
Expand All @@ -53,7 +54,7 @@
from corehq.apps.custom_data_fields.models import (
CUSTOM_DATA_FIELD_PREFIX,
)
from corehq.apps.domain.auth import get_connectid_userinfo
from corehq.apps.domain.auth import connectid_token_auth, get_connectid_userinfo
from corehq.apps.domain.decorators import (
domain_admin_required,
login_and_domain_required,
Expand Down Expand Up @@ -81,6 +82,7 @@
can_edit_workers_location,
location_safe
)
from corehq.apps.mobile_auth.utils import generate_aes_key
from corehq.apps.ota.utils import demo_restore_date_created, turn_off_demo_mode
from corehq.apps.registration.forms import (
MobileWorkerAccountConfirmationBySMSForm,
Expand Down Expand Up @@ -119,6 +121,7 @@
)
from corehq.apps.users.models import (
CommCareUser,
ConnectIDMessagingKey,
CouchUser,
DeactivateMobileWorkerTrigger,
check_and_send_limit_email,
Expand Down Expand Up @@ -1685,6 +1688,27 @@ def link_connectid_user(request, domain):
return HttpResponse()


@csrf_exempt
@connectid_token_auth
def connectid_messaging_key(request, domain):
link = get_object_or_404(ConnectIDUserLink, commcare_user=request.user, domain=request.domain)
key = generate_aes_key().decode("utf-8")
messaging_key, _ = ConnectIDMessagingKey.objects.get_or_create(
connectid_user_link=link, domain=request.domain, active=True, defaults={"key": key}
)
return JsonResponse({"key": messaging_key.key})


@csrf_exempt
@require_POST
@validate_request_hmac("CONNECTID_SECRET_KEY")
def update_connectid_messaging_consent(request, domain):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will come from the connectid server, not a specific user.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added. b9bc775

link = get_object_or_404(ConnectIDUserLink, commcare_user=request.user, domain=request.domain)
link.messaging_consent = request.POST.get("consent", False)
link.save()
return HttpResponse(status=200)


@waf_allow('XSS_BODY')
@csrf_exempt
@require_POST
Expand Down
1 change: 1 addition & 0 deletions migrations.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1256,6 +1256,7 @@ users
0070_alter_invitation_tableau_role
0071_rm_user_data
0072_remove_invitation_supply_point
0073_connectiduserlink_channel_id_and_more
util
0001_initial
0002_complaintbouncemeta_permanentbouncemeta_transientbounceemail
Expand Down
1 change: 1 addition & 0 deletions settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -1144,6 +1144,7 @@ def _pkce_required(client_id):
FCM_CREDS = None

CONNECTID_USERINFO_URL = 'http://localhost:8080/o/userinfo'
CONNECTID_SECRET_KEY = ''

MAX_MOBILE_UCR_LIMIT = 300 # used in corehq.apps.cloudcare.util.should_restrict_web_apps_usage
MAX_MOBILE_UCR_SIZE = 100000 # max number of rows allowed when syncing a mobile UCR
Expand Down
Loading