Skip to content

Commit

Permalink
Merge pull request #1015 from fecgov/release/sprint-46
Browse files Browse the repository at this point in the history
Release/sprint 46
  • Loading branch information
toddlees authored Aug 20, 2024
2 parents 143ae95 + 49e8daf commit 6889b9e
Show file tree
Hide file tree
Showing 52 changed files with 2,960 additions and 306 deletions.
1 change: 1 addition & 0 deletions .safety.dependency.ignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@
# Example:
# 40104 2022-01-15
#
71600 2024-09-01 # gunicorn <23.0.0 vulnerability
1 change: 0 additions & 1 deletion bin/run-api.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,4 @@ cd django-backend

# Run migrations and application
./manage.py migrate --no-input --traceback --verbosity 3 &&
python manage.py create_committee_views &&
exec gunicorn --bind 0.0.0.0:8080 fecfiler.wsgi -w 9
44 changes: 0 additions & 44 deletions django-backend/fecfiler/authentication/test_views.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,10 @@
from unittest.mock import Mock
from django.test import RequestFactory, TestCase
from django.contrib.auth import get_user_model
from fecfiler.authentication.views import (
handle_invalid_login,
handle_valid_login,
)

from .views import (
generate_username,
login_dot_gov_logout,
login_redirect,
logout_redirect,
)

UserModel = get_user_model()


Expand All @@ -24,42 +16,6 @@ def setUp(self):
self.user = UserModel.objects.get(id="12345678-aaaa-bbbb-cccc-111122223333")
self.factory = RequestFactory()

def test_login_dot_gov_logout_happy_path(self):
test_state = "test_state"

mock_request = Mock()
mock_request.session = Mock()
mock_request.get_signed_cookie.return_value = test_state

retval = login_dot_gov_logout(mock_request)
self.maxDiff = None
self.assertEqual(
retval,
(
"https://idp.int.identitysandbox.gov"
"/openid_connect/logout?"
"client_id=None"
"&post_logout_redirect_uri=None"
"&state=test_state"
),
)

def test_login_dot_gov_login_redirect(self):
request = self.factory.get("/")
request.user = self.user
request.session = {}
retval = login_redirect(request)
self.assertEqual(retval.status_code, 302)

def test_login_dot_gov_logout_redirect(self):
retval = logout_redirect(self.factory.get("/"))
self.assertEqual(retval.status_code, 302)

def test_generate_username(self):
test_uuid = "test_uuid"
retval = generate_username(test_uuid)
self.assertEqual(test_uuid, retval)

def test_invalid_login(self):
resp = handle_invalid_login("random_username")
self.assertEqual(resp.status_code, 401)
Expand Down
4 changes: 0 additions & 4 deletions django-backend/fecfiler/authentication/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,11 @@
from .views import (
authenticate_login,
authenticate_logout,
login_redirect,
logout_redirect,
)


# The API URLs are now determined automatically by the router.
urlpatterns = [
path("user/login/authenticate", authenticate_login),
path("auth/logout", authenticate_logout),
path("auth/login-redirect", login_redirect),
path("auth/logout-redirect", logout_redirect),
]
17 changes: 17 additions & 0 deletions django-backend/fecfiler/authentication/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import logging


from fecfiler.settings import (
FFAPI_LOGIN_DOT_GOV_COOKIE_NAME,
FFAPI_COOKIE_DOMAIN,
FFAPI_TIMEOUT_COOKIE_NAME,
)

LOGGER = logging.getLogger(__name__)


def delete_user_logged_in_cookies(response):
response.delete_cookie(FFAPI_LOGIN_DOT_GOV_COOKIE_NAME, domain=FFAPI_COOKIE_DOMAIN)
response.delete_cookie(FFAPI_TIMEOUT_COOKIE_NAME, domain=FFAPI_COOKIE_DOMAIN)
response.delete_cookie("oidc_state")
response.delete_cookie("csrftoken", domain=FFAPI_COOKIE_DOMAIN)
65 changes: 5 additions & 60 deletions django-backend/fecfiler/authentication/views.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from django.http import HttpResponseRedirect, HttpResponse
from django.http import HttpResponse
from django.contrib.auth import authenticate, logout, login
from django.views.decorators.http import require_http_methods
from rest_framework.decorators import (
Expand All @@ -7,22 +7,17 @@
api_view,
)
from fecfiler.settings import (
LOGIN_REDIRECT_CLIENT_URL,
OIDC_RP_CLIENT_ID,
LOGOUT_REDIRECT_URL,
OIDC_OP_LOGOUT_ENDPOINT,
ALTERNATIVE_LOGIN,
FFAPI_COOKIE_DOMAIN,
FFAPI_LOGIN_DOT_GOV_COOKIE_NAME,
FFAPI_TIMEOUT_COOKIE_NAME,
)

from fecfiler.authentication.utils import delete_user_logged_in_cookies

from rest_framework.response import Response
from rest_framework import status
from urllib.parse import urlencode
from django.http import JsonResponse
import structlog


logger = structlog.get_logger(__name__)

"""
Expand All @@ -32,27 +27,6 @@
USERNAME_PASSWORD = "USERNAME_PASSWORD"


def login_dot_gov_logout(request):
client_id = OIDC_RP_CLIENT_ID
post_logout_redirect_uri = LOGOUT_REDIRECT_URL
state = request.get_signed_cookie("oidc_state")

params = {
"client_id": client_id,
"post_logout_redirect_uri": post_logout_redirect_uri,
"state": state,
}
query = urlencode(params)
op_logout_url = OIDC_OP_LOGOUT_ENDPOINT
redirect_url = f"{op_logout_url}?{query}"

return redirect_url


def generate_username(uuid):
return uuid


def handle_valid_login(user):
logger.debug(f"Successful login: {user}")
response = HttpResponse()
Expand All @@ -61,36 +35,7 @@ def handle_valid_login(user):

def handle_invalid_login(username):
logger.debug(f"Unauthorized login attempt: {username}")
return HttpResponse('Unauthorized', status=401)


def delete_user_logged_in_cookies(response):
response.delete_cookie(FFAPI_LOGIN_DOT_GOV_COOKIE_NAME, domain=FFAPI_COOKIE_DOMAIN)
response.delete_cookie(FFAPI_TIMEOUT_COOKIE_NAME, domain=FFAPI_COOKIE_DOMAIN)
response.delete_cookie("oidc_state")
response.delete_cookie("csrftoken", domain=FFAPI_COOKIE_DOMAIN)


@api_view(["GET"])
@require_http_methods(["GET"])
def login_redirect(request):
redirect = HttpResponseRedirect(LOGIN_REDIRECT_CLIENT_URL)
redirect.set_cookie(
FFAPI_LOGIN_DOT_GOV_COOKIE_NAME,
"true",
domain=FFAPI_COOKIE_DOMAIN,
secure=True,
)
return redirect


@api_view(["GET"])
@require_http_methods(["GET"])
@permission_classes([])
def logout_redirect(request):
response = HttpResponseRedirect(LOGIN_REDIRECT_CLIENT_URL)
delete_user_logged_in_cookies(response)
return response
return HttpResponse("Unauthorized", status=401)


@api_view(["GET", "POST"])
Expand Down
30 changes: 30 additions & 0 deletions django-backend/fecfiler/committee_accounts/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Updating Committee Views in the Database

When changes are made to the Committee Account model, it is necessary to update
the Committee Views for each committee in the database. To do this, create a new
migration in the Committee Account app and refer to the following boilerplate code:

```
from django.db import migrations
from fecfiler.committee_accounts.views import create_committee_view
def update_committee_views(apps, schema_editor):
CommitteeAccount = apps.get_model("committee_accounts", "CommitteeAccount") # noqa
for committee in CommitteeAccount.objects.all():
create_committee_view(committee.id)
class Migration(migrations.Migration):
dependencies = [(
'committee_accounts',
'XXXX_previous_migration_name'
)]
operations = [
migrations.RunPython(
update_committee_views,
migrations.RunPython.noop,
),
]
```
31 changes: 31 additions & 0 deletions django-backend/fecfiler/committee_accounts/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from fecfiler.committee_accounts.views import (
register_committee,
CommitteeMembershipViewSet,
check_email_match,
)
from fecfiler.user.models import User
from django.core.management import call_command
Expand Down Expand Up @@ -189,3 +190,33 @@ def test_add_membership_requires_correct_parameters(self):
response.data,
"This email is taken by an existing membership to this committee",
)


class CheckEmailMatchTestCase(TestCase):
def test_no_f1_email(self):
result = check_email_match("[email protected]", None)
self.assertEqual(result, "No email provided in F1")

def test_no_match(self):
f1_emails = "[email protected];[email protected]"
result = check_email_match("[email protected]", f1_emails)
self.assertEqual(
result, "Email [email protected] does not match committee email"
)

def test_match_semicolon(self):
f1_emails = "[email protected];[email protected]"
result = check_email_match("[email protected]", f1_emails)
self.assertIsNone(result)
result = check_email_match("[email protected]", f1_emails)
self.assertIsNone(result)

def test_match_comma(self):
f1_emails = "[email protected],[email protected]"
result = check_email_match("[email protected]", f1_emails)
self.assertIsNone(result)

def test_email_matching_case_insensitive(self):
f1_emails = "[email protected];[email protected]"
result = check_email_match("[email protected]", f1_emails)
self.assertIsNone(result)
48 changes: 28 additions & 20 deletions django-backend/fecfiler/committee_accounts/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from django.db import connection
import structlog
from django.http import HttpResponse
import re

logger = structlog.get_logger(__name__)

Expand Down Expand Up @@ -206,12 +207,35 @@ def remove_member(self, request, pk: UUID):
return HttpResponse("Member removed")


def check_email_match(email, f1_emails):
"""
Check if the provided email matches any of the committee emails.
Args:
email (str): The email to be checked.
f1_emails (str): A string containing a list of committee emails separated
by commas or semicolons.
Returns:
str or None: If the provided email does not match any of the committee emails,
returns a string indicating the mismatch. Otherwise, returns None.
"""
if not f1_emails:
return "No email provided in F1"
else:
f1_email_lowercase = f1_emails.lower()
f1_emails = re.split(r"[;,]", f1_email_lowercase)
if email.lower() not in f1_emails:
return f"Email {email} does not match committee email"
return None


def register_committee(committee_id, user):
email = user.email

if MOCK_OPENFEC_REDIS_URL:
f1 = recent_f1(committee_id)
f1_email = (f1 or {}).get("email")
f1_emails = (f1 or {}).get("email")
else:
f1 = retrieve_recent_f1(committee_id)
dot_fec_url = (f1 or {}).get("fec_url")
Expand All @@ -222,24 +246,9 @@ def register_committee(committee_id, user):
)
dot_fec_content = response.content.decode("utf-8")
f1_line = dot_fec_content.split("\n")[1]
f1_email = f1_line.split(FS_STR)[11]

failure_reason = None
f1_emails = f1_line.split(FS_STR)[11]

if not f1_email:
failure_reason = "No email provided in F1"
else:
f1_email_lowercase = f1_email.lower()
f1_emails = []
if ";" in f1_email_lowercase:
f1_emails = f1_email_lowercase.split(";")
elif "," in f1_email_lowercase:
f1_emails = f1_email_lowercase.split(",")
else:
f1_emails = [f1_email_lowercase]

if email.lower() not in f1_emails:
failure_reason = f"Email {email} does not match committee email"
failure_reason = check_email_match(email, f1_emails)

existing_account = CommitteeAccount.objects.filter(committee_id=committee_id).first()
if existing_account:
Expand Down Expand Up @@ -270,6 +279,5 @@ def create_committee_view(committee_uuid):
)
definition = cursor.mogrify(sql, params).decode("utf-8")
cursor.execute(
f"DROP VIEW IF EXISTS {view_name};"
f"CREATE VIEW {view_name} as {definition}"
f"CREATE OR REPLACE VIEW {view_name} as {definition}"
)
39 changes: 39 additions & 0 deletions django-backend/fecfiler/devops/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Devops

A module to support devops tasks for fecfile web api.

## Commands

This module contains several commands (and supporting utilities) to assist devops tasks:

### gen_and_stage_cert.py
This command is used to generate and stage the login dot gov cert. The pk is stored in the credential service while the public cert is stored in s3.

This command can be executed as follows:
```
cf rt fecfile-web-api --command 'python django-backend/manage.py gen_and_stage_cert "<token>" <cf_space> <cf_creds_service_name>'
```

### install_cert.py
This command is used to install the staged the login dot gov cert. This should be run after the public cert is uploaded via login.gov and activated.

This command can be executed as follows:
```
cf rt fecfile-web-api --command 'python django-backend/manage.py install_cert "<token>" <cf_space> <cf_creds_service_name>'
```

### backout_cert.py
This command is used to backout the login dot gov cert after the install cert command has been run. This should be run only if we need to revert to the prior pk in the credential service.

This command can be executed as follows:
```
cf rt fecfile-web-api --command 'python django-backend/manage.py backout_cert "<token>" <cf_space> <cf_creds_service_name>'
```

### update_creds_service.py
This command is used to update the creds service with a json structure of key/value pairs.

This command can be executed as follows:
```
cf rt fecfile-web-api --command 'python django-backend/manage.py update_creds_service "<token>" <cf_space> <cf_creds_service_name> "<json_with_escaped_double_quotes_around_keys_and_values>"'
```
File renamed without changes.
Loading

0 comments on commit 6889b9e

Please sign in to comment.