Skip to content

Commit 2fe4647

Browse files
authored
feat: endpoint for imapd to authenticate against (ietf-tools#5295)
* feat: endpoint for imapd to authenticate against * chore: remove unintended whitespace * fix: be stricter in matching User
1 parent f810eda commit 2fe4647

4 files changed

Lines changed: 154 additions & 0 deletions

File tree

ietf/api/ietf_utils.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Copyright The IETF Trust 2023, All Rights Reserved
2+
3+
# This is not utils.py because Tastypie implicitly consumes ietf.api.utils.
4+
# See ietf.api.__init__.py for details.
5+
6+
from django.conf import settings
7+
8+
def is_valid_token(endpoint, token):
9+
# This is where we would consider integration with vault
10+
# Settings implementation for now.
11+
if hasattr(settings, "APP_API_TOKENS"):
12+
token_store = settings.APP_API_TOKENS
13+
if endpoint in token_store and token in token_store[endpoint]:
14+
return True
15+
return False

ietf/api/tests.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from django.apps import apps
1414
from django.conf import settings
1515
from django.test import Client
16+
from django.test.utils import override_settings
1617
from django.urls import reverse as urlreverse
1718
from django.utils import timezone
1819

@@ -530,6 +531,101 @@ def test_api_appauth(self):
530531
jsondata = r.json()
531532
self.assertEqual(jsondata['success'], True)
532533

534+
class DirectAuthApiTests(TestCase):
535+
536+
def setUp(self):
537+
super().setUp()
538+
self.valid_token = "nSZJDerbau6WZwbEAYuQ"
539+
self.invalid_token = self.valid_token
540+
while self.invalid_token == self.valid_token:
541+
self.invalid_token = User.objects.make_random_password(20)
542+
self.url = urlreverse("ietf.api.views.directauth")
543+
self.valid_person = PersonFactory()
544+
self.valid_password = self.valid_person.user.username+"+password"
545+
self.invalid_password = self.valid_password
546+
while self.invalid_password == self.valid_password:
547+
self.invalid_password = User.objects.make_random_password(20)
548+
549+
self.valid_body_with_good_password = self.post_dict(authtoken=self.valid_token, username=self.valid_person.user.username, password=self.valid_password)
550+
self.valid_body_with_bad_password = self.post_dict(authtoken=self.valid_token, username=self.valid_person.user.username, password=self.invalid_password)
551+
self.valid_body_with_unknown_user = self.post_dict(authtoken=self.valid_token, username="notauser@nowhere.nada", password=self.valid_password)
552+
553+
def post_dict(self, authtoken, username, password):
554+
data = dict()
555+
if authtoken is not None:
556+
data["authtoken"] = authtoken
557+
if username is not None:
558+
data["username"] = username
559+
if password is not None:
560+
data["password"] = password
561+
return dict(data = json.dumps(data))
562+
563+
def response_data(self, response):
564+
try:
565+
data = json.loads(response.content)
566+
except json.decoder.JSONDecodeError:
567+
data = None
568+
self.assertIsNotNone(data)
569+
return data
570+
571+
def test_bad_methods(self):
572+
for method in (self.client.get, self.client.put, self.client.head, self.client.delete, self.client.patch):
573+
r = method(self.url)
574+
self.assertEqual(r.status_code, 405)
575+
576+
def test_bad_post(self):
577+
for bad in [
578+
self.post_dict(authtoken=None, username=self.valid_person.user.username, password=self.valid_password),
579+
self.post_dict(authtoken=self.valid_token, username=None, password=self.valid_password),
580+
self.post_dict(authtoken=self.valid_token, username=self.valid_person.user.username, password=None),
581+
self.post_dict(authtoken=None, username=None, password=self.valid_password),
582+
self.post_dict(authtoken=self.valid_token, username=None, password=None),
583+
self.post_dict(authtoken=None, username=self.valid_person.user.username, password=None),
584+
self.post_dict(authtoken=None, username=None, password=None),
585+
]:
586+
r = self.client.post(self.url, bad)
587+
self.assertEqual(r.status_code, 200)
588+
data = self.response_data(r)
589+
self.assertEqual(data["result"], "failure")
590+
self.assertEqual(data["reason"], "invalid post")
591+
592+
bad = dict(authtoken=self.valid_token, username=self.valid_person.user.username, password=self.valid_password)
593+
r = self.client.post(self.url, bad)
594+
self.assertEqual(r.status_code, 200)
595+
data = self.response_data(r)
596+
self.assertEqual(data["result"], "failure")
597+
self.assertEqual(data["reason"], "invalid post")
598+
599+
def test_notokenstore(self):
600+
self.assertFalse(hasattr(settings, "APP_API_TOKENS"))
601+
r = self.client.post(self.url,self.valid_body_with_good_password)
602+
self.assertEqual(r.status_code, 200)
603+
data = self.response_data(r)
604+
self.assertEqual(data["result"], "failure")
605+
self.assertEqual(data["reason"], "invalid authtoken")
606+
607+
@override_settings(APP_API_TOKENS={"ietf.api.views.directauth":"nSZJDerbau6WZwbEAYuQ"})
608+
def test_bad_username(self):
609+
r = self.client.post(self.url, self.valid_body_with_unknown_user)
610+
self.assertEqual(r.status_code, 200)
611+
data = self.response_data(r)
612+
self.assertEqual(data["result"], "failure")
613+
self.assertEqual(data["reason"], "authentication failed")
614+
615+
@override_settings(APP_API_TOKENS={"ietf.api.views.directauth":"nSZJDerbau6WZwbEAYuQ"})
616+
def test_bad_password(self):
617+
r = self.client.post(self.url, self.valid_body_with_bad_password)
618+
self.assertEqual(r.status_code, 200)
619+
data = self.response_data(r)
620+
self.assertEqual(data["result"], "failure")
621+
self.assertEqual(data["reason"], "authentication failed")
622+
623+
@override_settings(APP_API_TOKENS={"ietf.api.views.directauth":"nSZJDerbau6WZwbEAYuQ"})
624+
def test_good_password(self):
625+
r = self.client.post(self.url, self.valid_body_with_good_password)
626+
self.assertEqual(r.status_code, 200)
627+
data = self.response_data(r)
628+
self.assertEqual(data["result"], "success")
533629

534630
class TastypieApiTestCase(ResourceTestCaseMixin, TestCase):
535631
def __init__(self, *args, **kwargs):

ietf/api/urls.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@
5858
# latest versions
5959
url(r'^rfcdiff-latest-json/%(name)s(?:-%(rev)s)?(\.txt|\.html)?/?$' % settings.URL_REGEXPS, api_views.rfcdiff_latest_json),
6060
url(r'^rfcdiff-latest-json/(?P<name>[Rr][Ff][Cc] [0-9]+?)(\.txt|\.html)?/?$', api_views.rfcdiff_latest_json),
61+
# direct authentication
62+
url(r'^directauth/?$', api_views.directauth),
6163
]
6264

6365
# Additional (standard) Tastypie endpoints

ietf/api/views.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from jwcrypto.jwk import JWK
1010

1111
from django.conf import settings
12+
from django.contrib.auth import authenticate
1213
from django.contrib.auth.decorators import login_required
1314
from django.contrib.auth.models import User
1415
from django.core.exceptions import ValidationError
@@ -32,6 +33,7 @@
3233
from ietf.person.models import Person, Email
3334
from ietf.api import _api_list
3435
from ietf.api.serializer import JsonExportMixin
36+
from ietf.api.ietf_utils import is_valid_token
3537
from ietf.doc.utils import fuzzy_find_documents
3638
from ietf.ietfauth.views import send_account_creation_email
3739
from ietf.ietfauth.utils import role_required
@@ -388,3 +390,42 @@ def rfcdiff_latest_json(request, name, rev=None):
388390
if not response:
389391
raise Http404
390392
return HttpResponse(json.dumps(response), content_type='application/json')
393+
394+
@csrf_exempt
395+
def directauth(request):
396+
if request.method == "POST":
397+
raw_data = request.POST.get("data", None)
398+
if raw_data:
399+
try:
400+
data = json.loads(raw_data)
401+
except json.decoder.JSONDecodeError:
402+
data = None
403+
404+
if raw_data is None or data is None:
405+
return HttpResponse(json.dumps(dict(result="failure",reason="invalid post")), content_type='application/json')
406+
407+
authtoken = data.get('authtoken', None)
408+
username = data.get('username', None)
409+
password = data.get('password', None)
410+
411+
if any([item is None for item in (authtoken, username, password)]):
412+
return HttpResponse(json.dumps(dict(result="failure",reason="invalid post")), content_type='application/json')
413+
414+
if not is_valid_token("ietf.api.views.directauth", authtoken):
415+
return HttpResponse(json.dumps(dict(result="failure",reason="invalid authtoken")), content_type='application/json')
416+
417+
user_query = User.objects.filter(username__iexact=username)
418+
419+
# Matching email would be consistent with auth everywhere else in the app, but until we can map users well
420+
# in the imap server, people's annotations are associated with a very specific login.
421+
# If we get a second user of this API, add an "allow_any_email" argument.
422+
423+
424+
# Note well that we are using user.username, not what was passed to the API.
425+
if user_query.count() == 1 and authenticate(username = user_query.first().username, password = password):
426+
return HttpResponse(json.dumps(dict(result="success")), content_type='application/json')
427+
428+
return HttpResponse(json.dumps(dict(result="failure", reason="authentication failed")), content_type='application/json')
429+
430+
else:
431+
return HttpResponse(status=405)

0 commit comments

Comments
 (0)