From 15b6210ce1c40241cf1a2705f162de5bcc9b0643 Mon Sep 17 00:00:00 2001 From: Benjamin Moody Date: Mon, 30 Oct 2023 16:14:59 -0400 Subject: [PATCH] [WIP] Add views and forms for verifying AWS user identity. In order for a person to verify their AWS identity, they need to provide a digital signature, in the form of a signed URL that includes their account ID and user ID in the path. We further require the URL to include the domain name of the site, and the user's primary email address, to prevent misuse. This signed URL can be generated using the AWS CLI. However, the URL must be exactly correct; if it is wrong, it is difficult to tell why. In order to hopefully avoid confusion, we first ask the person to run 'aws sts get-caller-identity'; based on that, we tell them the exact 'aws s3 presign' command they need to run. --- physionet-django/user/forms.py | 92 ++++++++++++++++++- .../user/templates/user/edit_cloud.html | 50 +++++++++- .../user/templates/user/edit_cloud_aws.html | 28 ++++++ physionet-django/user/urls.py | 1 + physionet-django/user/views.py | 60 +++++++++++- 5 files changed, 223 insertions(+), 8 deletions(-) create mode 100644 physionet-django/user/templates/user/edit_cloud_aws.html diff --git a/physionet-django/user/forms.py b/physionet-django/user/forms.py index 5ca6e18c13..5623866419 100644 --- a/physionet-django/user/forms.py +++ b/physionet-django/user/forms.py @@ -1,4 +1,5 @@ import datetime +import json import time from django import forms @@ -14,6 +15,11 @@ from django.utils.html import mark_safe from django.utils.translation import gettext_lazy from physionet.utility import validate_pdf_file_type +from user.awsverification import ( + AWSVerificationFailed, + get_aws_verification_command, + check_aws_verification_url, +) from user.models import ( AssociatedEmail, CloudInformation, @@ -28,8 +34,14 @@ ) from user.trainingreport import TrainingCertificateError, find_training_report_url from user.userfiles import UserFiles -from user.validators import UsernameValidator, validate_name, validate_training_file_size -from user.validators import validate_institutional_email +from user.validators import ( + UsernameValidator, + validate_aws_id, + validate_aws_userid, + validate_institutional_email, + validate_name, + validate_training_file_size, +) from user.widgets import ProfilePhotoInput from django.db.models import OuterRef, Exists @@ -675,10 +687,9 @@ class CloudForm(forms.ModelForm): """ class Meta: model = CloudInformation - fields = ('gcp_email','aws_id',) + fields = ('gcp_email',) labels = { 'gcp_email': 'Google (Email)', - 'aws_id': 'Amazon (ID)', } def __init__(self, *args, **kwargs): # Email choices are those belonging to a user @@ -688,6 +699,79 @@ def __init__(self, *args, **kwargs): self.fields['gcp_email'].required = False +class AWSIdentityForm(forms.Form): + aws_identity = forms.CharField( + label="Caller identity", max_length=2000, + widget=forms.Textarea(attrs={ + 'rows': 5, + 'placeholder': '{"UserId": "...", "Account": "...", "Arn": "..."}' + }) + ) + + def clean(self): + try: + identity = super().clean()['aws_identity'] + data = json.loads(identity) + aws_account = data['Account'] + aws_userid = data['UserId'] + except (TypeError, KeyError, ValueError): + raise forms.ValidationError( + mark_safe("Copy and paste the output of the " + "aws sts get-caller-identity command.")) + validate_aws_id(aws_account) + validate_aws_userid(aws_userid) + return { + 'aws_account': aws_account, + 'aws_userid': aws_userid + } + + +class AWSVerificationForm(forms.Form): + signed_url = forms.CharField( + label="Signed URL", max_length=2000, + widget=forms.Textarea(attrs={ + 'rows': 6, + 'placeholder': 'https://...' + }) + ) + + def __init__(self, user, site_domain, aws_account, aws_userid, + **kwargs): + super().__init__(**kwargs) + self.user = user + self.site_domain = site_domain + self.aws_account = aws_account + self.aws_userid = aws_userid + + def aws_verification_command(self): + return get_aws_verification_command(site_domain=self.site_domain, + user_email=self.user.email, + aws_account=self.aws_account, + aws_userid=self.aws_userid) + + def clean(self): + data = super().clean() + signed_url = data['signed_url'].strip() + try: + info = check_aws_verification_url(site_domain=self.site_domain, + user_email=self.user.email, + signed_url=signed_url) + except AWSVerificationFailed: + raise forms.ValidationError("Invalid verification URL") + + validate_aws_id(info['account']) + validate_aws_userid(info['userid']) + data.update(info) + return data + + def save(self): + cloud_info = CloudInformation.objects.get_or_create(user=self.user)[0] + cloud_info.aws_id = self.cleaned_data['account'] + cloud_info.aws_userid = self.cleaned_data['userid'] + cloud_info.aws_verification_datetime = timezone.now() + cloud_info.save() + + # class ActivationForm(forms.ModelForm): class ActivationForm(forms.Form): """A form for creating new users. Includes all the required diff --git a/physionet-django/user/templates/user/edit_cloud.html b/physionet-django/user/templates/user/edit_cloud.html index 9056e0eb40..0928da94e5 100644 --- a/physionet-django/user/templates/user/edit_cloud.html +++ b/physionet-django/user/templates/user/edit_cloud.html @@ -12,12 +12,58 @@

Edit Cloud Details

  • Follow the instructions to request access. If instructions for cloud access are not shown, the project is not currently available on the cloud.
  • +

    Google Cloud Platform

    +

    {% csrf_token %} - {% include "inline_form_snippet.html" %} - + {% include "inline_form_snippet.html" with form=gcp_form %} +
    +{% if aws_verification_available %} +

    Amazon Web Services

    +
    + {% csrf_token %} + {% if user.cloud_information.aws_verification_datetime %} +
    +
      +
    • + +
      +
      Account
      +
      {{ user.cloud_information.aws_id }}
      +
      User ID
      +
      {{ user.cloud_information.aws_userid }}
      +
      +
    • +
    +
    + {% else %} +
    +

    To link your Amazon Web Services account using the + AWS Command Line Interface: +

    +
      +
    1. + Open a terminal and run the following command: +
      aws sts get-caller-identity
      +
    2. +
    3. + Copy and paste the output into the box below. + {% include "form_snippet_no_labels.html" with form=aws_form %} +
    4. +
    + + {% endif %} +
    +{% endif %} {% endblock %} diff --git a/physionet-django/user/templates/user/edit_cloud_aws.html b/physionet-django/user/templates/user/edit_cloud_aws.html new file mode 100644 index 0000000000..0985807f9b --- /dev/null +++ b/physionet-django/user/templates/user/edit_cloud_aws.html @@ -0,0 +1,28 @@ +{% extends "user/settings.html" %} + +{% block title %}Verify AWS Account{% endblock %} + +{% block main_content %} +

    Verify AWS Account

    +
    + +
    + {% csrf_token %} +

    + To verify your Amazon Web Services account using the + AWS Command Line Interface: +

    +
      +
    1. + Open a terminal and run the following command (one line): +
      {{ form.aws_verification_command }}
      +
    2. +
    3. + Copy and paste the output into the box below. + {% include "form_snippet_no_labels.html" %} +
    4. +
    + +
    +{% endblock %} diff --git a/physionet-django/user/urls.py b/physionet-django/user/urls.py index 8723f37e62..0fae8e5b58 100644 --- a/physionet-django/user/urls.py +++ b/physionet-django/user/urls.py @@ -15,6 +15,7 @@ path("settings/emails/", views.edit_emails, name="edit_emails"), path("settings/username/", views.edit_username, name="edit_username"), path("settings/cloud/", views.edit_cloud, name="edit_cloud"), + path("settings/cloud/aws/", views.edit_cloud_aws, name="edit_cloud_aws"), path("settings/orcid/", views.edit_orcid, name="edit_orcid"), path("authorcid/", views.auth_orcid, name="auth_orcid"), path( diff --git a/physionet-django/user/views.py b/physionet-django/user/views.py index 05628b8699..6faeb8b502 100644 --- a/physionet-django/user/views.py +++ b/physionet-django/user/views.py @@ -44,6 +44,7 @@ from project.models import Author, DUASignature, DUA, PublishedProject from requests_oauthlib import OAuth2Session from user import forms, validators +from user.awsverification import aws_verification_available from user.models import ( AssociatedEmail, CodeOfConduct, @@ -914,7 +915,7 @@ def edit_cloud(request): user = request.user cloud_info = CloudInformation.objects.get_or_create(user=user)[0] form = forms.CloudForm(instance=cloud_info) - if request.method == 'POST': + if request.method == 'POST' and 'save-gcp' in request.POST: form = forms.CloudForm(instance=cloud_info, data=request.POST) if form.is_valid(): form.save() @@ -922,7 +923,62 @@ def edit_cloud(request): else: messages.error(request, 'Invalid submission. See errors below.') - return render(request, 'user/edit_cloud.html', {'form':form, 'user':user}) + if request.method == 'POST' and 'delete-aws' in request.POST: + cloud_info.aws_account = None + cloud_info.aws_userid = None + cloud_info.aws_verification_datetime = None + cloud_info.save() + + aws_form = forms.AWSIdentityForm() + if request.method == 'POST' and 'save-aws' in request.POST: + aws_form = forms.AWSIdentityForm(data=request.POST) + if aws_form.is_valid(): + response = redirect('edit_cloud_aws') + request.session['new_aws_account'] = \ + aws_form.cleaned_data['aws_account'] + request.session['new_aws_userid'] = \ + aws_form.cleaned_data['aws_userid'] + return redirect('edit_cloud_aws') + else: + messages.error(request, 'Invalid submission. See errors below.') + + return render(request, 'user/edit_cloud.html', { + 'user': user, + 'gcp_form': form, + 'aws_form': aws_form, + 'aws_verification_available': aws_verification_available(), + }) + + +@login_required +def edit_cloud_aws(request): + if not aws_verification_available(): + return redirect('edit_cloud') + + site_domain = get_current_site(request).domain + aws_account = request.session.get('new_aws_account', '') + aws_userid = request.session.get('new_aws_userid', '') + form = forms.AWSVerificationForm(user=request.user, + site_domain=site_domain, + aws_account=aws_account, + aws_userid=aws_userid) + if request.method == 'POST' and 'signed_url' in request.POST: + form = forms.AWSVerificationForm(user=request.user, + site_domain=site_domain, + aws_account=aws_account, + aws_userid=aws_userid, + data=request.POST) + if form.is_valid(): + form.save() + request.session.pop('new_aws_account') + request.session.pop('new_aws_userid') + messages.success(request, 'Your cloud information has been saved.') + return redirect('edit_cloud') + else: + messages.error(request, 'Invalid submission. See errors below.') + + return render(request, 'user/edit_cloud_aws.html', {'form': form}) + @login_required def view_agreements(request):