Skip to content
This repository was archived by the owner on Apr 29, 2022. It is now read-only.

Commit c6d9c52

Browse files
authored
Add an API for authentication in Matrix (#1397)
* initial * renamed to something more sensible * Add a required checkbox to the CfP proposal submission page. The checkbox content is not stored in the database, but submission will fail unless checked. * added test to verify that speakers do have to agree to the speaker release agreement * initial * renamed to something more sensible * Added a template simple_tag to list a user tickets for the current conference. Uses functionality already implemented. * Added documentation * Added a template simple_tag to list a user tickets for the current conference. Uses functionality already implemented. * Upgraded jquery-flot to v0.8.3 for compatibility with django admin jquery version - v3.5.1 * Squashed commit of the following: commit d382a85 Author: Marc-Andre Lemburg <[email protected]> Date: Tue May 11 12:18:14 2021 +0200 Update bulk coupon docs (#1396) * Add update functionality to the bulk coupon script * Update create coupon script docs. commit ad14175 Author: Marc-Andre Lemburg <[email protected]> Date: Tue May 11 11:52:08 2021 +0200 Add update functionality to the bulk coupon script (#1395) commit 3ad73b5 Author: Marc-Andre Lemburg <[email protected]> Date: Mon May 10 23:06:34 2021 +0200 Fix bulk coupon script (#1394) commit 7d2e20d Author: Marc-Andre Lemburg <[email protected]> Date: Tue May 4 15:22:53 2021 +0200 Fix a few additional corner cases for the automatic anchor generation (#1387) commit f7e2193 Author: Marc-Andre Lemburg <[email protected]> Date: Tue May 4 14:59:38 2021 +0200 Have the TOC script cleanup headers even more (#1385) They will now also ignore leading and trailing hyphens as well as non-breaking spaces. commit e09f78a Author: Marc-Andre Lemburg <[email protected]> Date: Tue May 4 14:11:12 2021 +0200 Scroll to the anchor, if given, after processing the TOC. (#1384) Fixes #1380. * initial * Added simple authentication api for matrix * forcing the use of HTTPS * better query to check is somebody is a speaker * quick fix * better documentation, slight improvement in the ticket query and somewhat more modular in case we want to develop more endpoints. * silly bug and fix * initial * cleaned things up a bit and added check for content type to ensure that we only process JSON data * typo * moved matrix auth api settings in pycon/settings.py and/or .env file (installation-specific)
1 parent d382a85 commit c6d9c52

File tree

4 files changed

+355
-0
lines changed

4 files changed

+355
-0
lines changed

conference/api.py

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
"""
2+
Matrix/Synapse custom authentication provider backend.
3+
4+
This allows a Matrix/Synapse installation to use a custom backaned (not part of
5+
this API) to authenticate users against epcon database.
6+
7+
The main (and currently the only) endpoint is
8+
9+
/api/v1/isauth
10+
11+
For more information about developing a custom auth backend for matrix/synapse
12+
please refer to https://github.com/matrix-org/synapse/blob/master/docs/\
13+
password_auth_providers.md
14+
"""
15+
from enum import Enum
16+
import json
17+
from functools import wraps
18+
from django.conf.urls import url as re_path
19+
from django.contrib.auth.hashers import check_password
20+
from django.db.models import Q
21+
from django.http import JsonResponse
22+
from django.views.decorators.csrf import csrf_exempt
23+
from conference.models import (
24+
AttendeeProfile,
25+
Conference,
26+
Speaker,
27+
TalkSpeaker,
28+
Ticket,
29+
)
30+
from pycon.settings import MATRIX_AUTH_API_DEBUG as DEBUG
31+
from pycon.settings import MATRIX_AUTH_API_ALLOWED_IPS as ALLOWED_IPS
32+
33+
34+
# Error Codes
35+
class ApiError(Enum):
36+
WRONG_METHOD = 1
37+
AUTH_ERROR = 2
38+
INPUT_ERROR = 3
39+
UNAUTHORIZED = 4
40+
WRONG_SCHEME = 5
41+
BAD_REQUEST = 6
42+
43+
44+
def _error(error: ApiError, msg: str) -> JsonResponse:
45+
return JsonResponse({
46+
'error': error.value,
47+
'message': f'{error.name}: {msg}'
48+
})
49+
50+
51+
def get_client_ip(request) -> str:
52+
"""
53+
Return the client IP.
54+
55+
This is a best effort way of fetching the client IP which does not protect
56+
against spoofing and hich tries to understand some proxying.
57+
58+
This should NOT be relied upon for serius stuff.
59+
"""
60+
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
61+
if x_forwarded_for:
62+
ip = x_forwarded_for.split(',')[0]
63+
else:
64+
ip = request.META.get('REMOTE_ADDR')
65+
return ip
66+
67+
68+
# Checkers
69+
def request_checker(checker, error_msg):
70+
"""
71+
Generic sanity check decorator on views.
72+
73+
It accepts two parameters:
74+
`checker`: a function that accepts a request and returns True if valid
75+
`error_msg`: what to return as error message if request is invalid
76+
77+
In case of invalid requests, it returns a BAD_REQUEST error.
78+
"""
79+
def decorator(fn):
80+
@wraps(fn)
81+
def wrapper(request, *args, **kwargs):
82+
if not checker(request):
83+
return _error(ApiError.BAD_REQUEST, error_msg)
84+
return fn(request, *args, **kwargs)
85+
return wrapper
86+
return decorator
87+
88+
89+
# Ensure that the view is called via an HTTPS request and return a JSON error
90+
# payload if not. If DEBUG = True, it has no effect.
91+
ensure_https_in_ops = request_checker(
92+
lambda r: DEBUG or r.is_secure(), 'please use HTTPS'
93+
)
94+
95+
# We use this instead of the bult-in decorator to return a JSON error
96+
# payload instead of a simple 405.
97+
ensure_post = request_checker(lambda r: r.method == 'POST', 'please use POST')
98+
99+
ensure_json_content_type = request_checker(
100+
lambda r: r.content_type == 'application/json', 'please send JSON'
101+
)
102+
103+
104+
def restrict_client_ip_to_allowed_list(fn):
105+
@wraps(fn)
106+
def wrapper(request, *args, **kwargs):
107+
# This is really a best effort attempt at detecting the client IP. It
108+
# does NOT handle IP spooding or any similar attack.
109+
best_effort_ip = get_client_ip(request)
110+
if ALLOWED_IPS and best_effort_ip not in ALLOWED_IPS:
111+
return _error(ApiError.UNAUTHORIZED, 'you are not authorized here')
112+
return fn(request, *args, **kwargs)
113+
return wrapper
114+
115+
116+
@csrf_exempt
117+
@ensure_post
118+
@ensure_https_in_ops
119+
@ensure_json_content_type
120+
@restrict_client_ip_to_allowed_list
121+
def isauth(request):
122+
"""
123+
Return whether or not the given email and password (sent via POST) are
124+
valid. If they are indeed valid, return the number and type of tickets
125+
assigned to the user, together with some other user metadata (see below).
126+
127+
Input via POST:
128+
{
129+
"email": str,
130+
"password": str (not encrypted)
131+
}
132+
133+
Output (JSON)
134+
{
135+
"username": str,
136+
"first_name": str,
137+
"last_name": str,
138+
"email": str,
139+
"is_staff": bool,
140+
"is_speaker": bool,
141+
"is_active": bool,
142+
"is_minor": bool,
143+
"tickets": [{"fare_name": str, "fare_code": str}*]
144+
}
145+
146+
Tickets, if any, are returned only for the currently active conference and
147+
only if ASSIGNED to the user identified by `email`.
148+
149+
In case of any error (including but not limited to if either email or
150+
password are incorrect/unknown), return
151+
{
152+
"message": str,
153+
"error": int
154+
}
155+
"""
156+
required_fields = {'email', 'password'}
157+
158+
try:
159+
data = json.loads(request.body)
160+
except json.decoder.JSONDecodeError as ex:
161+
return _error(ApiError.INPUT_ERROR, ex.msg)
162+
163+
if not isinstance(data, dict) or not required_fields.issubset(data.keys()):
164+
return _error(ApiError.INPUT_ERROR,
165+
'please provide credentials in JSON format')
166+
167+
# First, let's find the user/account profile given the email address
168+
try:
169+
profile = AttendeeProfile.objects.get(user__email=data['email'])
170+
except AttendeeProfile.DoesNotExist:
171+
return _error(ApiError.AUTH_ERROR, 'unknown user')
172+
173+
# Is the password OK?
174+
if not check_password(data['password'], profile.user.password):
175+
return _error(ApiError.AUTH_ERROR, 'authentication error')
176+
177+
# Get the tickets **assigned** to the user
178+
conference = Conference.objects.current()
179+
180+
tickets = Ticket.objects.filter(
181+
Q(fare__conference=conference.code)
182+
& Q(frozen=False) # i.e. the ticket was not cancelled
183+
& Q(orderitem__order___complete=True) # i.e. they paid
184+
& Q(user=profile.user) # i.e. assigned to user
185+
)
186+
187+
# A speaker is a user with at least one accepted talk in the current
188+
# conference.
189+
try:
190+
speaker = profile.user.speaker
191+
except Speaker.DoesNotExist:
192+
is_speaker = False
193+
else:
194+
is_speaker = TalkSpeaker.objects.filter(
195+
speaker=speaker,
196+
talk__conference=conference.code,
197+
talk__status='accepted'
198+
).count() > 0
199+
200+
payload = {
201+
"username": profile.user.username,
202+
"first_name": profile.user.first_name,
203+
"last_name": profile.user.last_name,
204+
"email": profile.user.email,
205+
"is_staff": profile.user.is_staff,
206+
"is_speaker": is_speaker,
207+
"is_active": profile.user.is_active,
208+
"is_minor": profile.is_minor,
209+
"tickets": [
210+
{"fare_name": t.fare.name, "fare_code": t.fare.code}
211+
for t in tickets
212+
]
213+
}
214+
215+
# Just a little nice to have thing when debugging: we can send in the POST
216+
# payload, all the fields that we want to override in the answer and they
217+
# will just be passed through regardless of what is in the DB. We just
218+
# remove the password to be on the safe side.
219+
if DEBUG:
220+
data.pop('password')
221+
payload.update(data)
222+
return JsonResponse(payload)
223+
224+
225+
urlpatterns = [
226+
re_path(r"^v1/isauth/$", isauth, name="isauth"),
227+
]

pycon/settings.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -726,3 +726,15 @@ def CONFERENCE_SCHEDULE_ATTENDEES(schedule, forecast):
726726
# Complete project setup.
727727
if not os.path.exists(LOGS_DIR):
728728
os.makedirs(LOGS_DIR)
729+
730+
# Matrix Auth API settings
731+
MATRIX_AUTH_API_DEBUG = config(
732+
'MATRIX_AUTH_API_DEBUG',
733+
default=False,
734+
cast=bool
735+
)
736+
MATRIX_AUTH_API_ALLOWED_IPS = config(
737+
'MATRIX_AUTH_API_ALLOWED_IPS',
738+
default='',
739+
cast=lambda v: [s.strip() for s in v.split(',')]
740+
)

pycon/urls.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from filebrowser.sites import site as fsite
77

88
from conference.accounts import urlpatterns as accounts_urls
9+
from conference.api import urlpatterns as api_urls
910
from conference.cart import urlpatterns as cart_urls
1011
from conference.cfp import urlpatterns as cfp_urls
1112
from conference.debug_panel import urlpatterns as debugpanel_urls
@@ -29,6 +30,7 @@
2930
re_path(r'^generic-content-page/with-sidebar/$', generic_content_page_with_sidebar),
3031
re_path(r'^user-panel/', include((user_panel_urls, 'conference'), namespace="user_panel")),
3132
re_path(r'^accounts/', include((accounts_urls, 'conference'), namespace="accounts")),
33+
re_path(r'^api/', include((api_urls, 'conference'), namespace="api")),
3234
re_path(r'^cfp/', include((cfp_urls, 'conference'), namespace="cfp")),
3335
re_path(r'^talks/', include((talks_urls, 'conference'), namespace="talks")),
3436
re_path(r'^profiles/', include((profiles_urls, 'conference'), namespace="profiles")),

tests/test_matrix_auth_api.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
from pytest import mark
2+
from django.urls import reverse
3+
from conference.api import ApiError
4+
from conference.models import TALK_STATUS
5+
from .common_tools import (
6+
get_default_conference,
7+
make_user,
8+
)
9+
from .factories import (
10+
TalkFactory,
11+
TalkSpeakerFactory,
12+
)
13+
14+
15+
@mark.django_db
16+
def test_non_post_error(client):
17+
payload = {'key': 'does not matter'}
18+
response = client.get(reverse('api:isauth'), payload,
19+
content_type='Application/json')
20+
result = response.json()
21+
assert result['error'] == ApiError.BAD_REQUEST.value
22+
23+
24+
@mark.django_db
25+
def test_non_json_post_error(client):
26+
payload = {'key': 'does not matter'}
27+
response = client.post(reverse('api:isauth'), payload)
28+
result = response.json()
29+
assert result['error'] == ApiError.BAD_REQUEST.value
30+
31+
32+
@mark.django_db
33+
def test_user_does_not_exist(client):
34+
payload = {'email': '[email protected]', 'password': 'hahahaha'}
35+
response = client.post(reverse('api:isauth'), payload,
36+
content_type='application/json')
37+
result = response.json()
38+
assert result['error'] == ApiError.AUTH_ERROR.value
39+
40+
41+
@mark.django_db
42+
def test_user_password_error(client):
43+
user = make_user(is_staff=False)
44+
payload = {'email': user.email, 'password': user.password + 'hahahaha'}
45+
response = client.post(reverse('api:isauth'), payload,
46+
content_type='application/json')
47+
result = response.json()
48+
assert result['error'] == ApiError.AUTH_ERROR.value
49+
50+
51+
@mark.django_db
52+
def test_non_staff_non_speaker_user_auth_success(client):
53+
get_default_conference()
54+
user = make_user(is_staff=False)
55+
payload = {'email': user.email, 'password': 'password123'}
56+
response = client.post(reverse('api:isauth'), payload,
57+
content_type='application/json')
58+
result = response.json()
59+
assert result['email'] == user.email
60+
assert result['first_name'] == user.first_name
61+
assert result['last_name'] == user.last_name
62+
assert result['is_staff'] is False
63+
assert result['is_speaker'] is False
64+
65+
66+
@mark.django_db
67+
def test_staff_non_speaker_user_auth_success(client):
68+
get_default_conference()
69+
user = make_user(is_staff=True)
70+
payload = {'email': user.email, 'password': 'password123'}
71+
response = client.post(reverse('api:isauth'), payload,
72+
content_type='application/json')
73+
result = response.json()
74+
assert result['email'] == user.email
75+
assert result['first_name'] == user.first_name
76+
assert result['last_name'] == user.last_name
77+
assert result['is_staff'] is True
78+
assert result['is_speaker'] is False
79+
80+
81+
@mark.django_db
82+
def test_staff_speaker_user_auth_success(client):
83+
get_default_conference()
84+
user = make_user(is_staff=True)
85+
talk = TalkFactory(created_by=user, status=TALK_STATUS.accepted)
86+
TalkSpeakerFactory(talk=talk, speaker__user=user)
87+
88+
payload = {'email': user.email, 'password': 'password123'}
89+
response = client.post(reverse('api:isauth'), payload,
90+
content_type='application/json')
91+
result = response.json()
92+
assert result['email'] == user.email
93+
assert result['first_name'] == user.first_name
94+
assert result['last_name'] == user.last_name
95+
assert result['is_staff'] is True
96+
assert result['is_speaker'] is True
97+
98+
99+
@mark.django_db
100+
def test_staff_proposed_speaker_user_auth_success(client):
101+
get_default_conference()
102+
user = make_user(is_staff=True)
103+
talk = TalkFactory(created_by=user, status=TALK_STATUS.proposed)
104+
TalkSpeakerFactory(talk=talk, speaker__user=user)
105+
106+
payload = {'email': user.email, 'password': 'password123'}
107+
response = client.post(reverse('api:isauth'), payload,
108+
content_type='application/json')
109+
result = response.json()
110+
assert result['email'] == user.email
111+
assert result['first_name'] == user.first_name
112+
assert result['last_name'] == user.last_name
113+
assert result['is_staff'] is True
114+
assert result['is_speaker'] is False

0 commit comments

Comments
 (0)