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

Commit c25f900

Browse files
committed
better documentation, slight improvement in the ticket query and somewhat more modular in case we want to develop more endpoints.
1 parent 09b02f8 commit c25f900

File tree

1 file changed

+93
-35
lines changed

1 file changed

+93
-35
lines changed

conference/api.py

Lines changed: 93 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,23 @@
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+
"""
115
from enum import Enum
216
import json
17+
from functools import wraps
318
from django.conf.urls import url as re_path
419
from django.contrib.auth.hashers import check_password
5-
from django.db.models import Q, Case, When, Value, BooleanField
20+
from django.db.models import Q
621
from django.http import JsonResponse
722
from django.views.decorators.csrf import csrf_exempt
823
from conference.models import (
@@ -34,7 +49,15 @@ def _error(error: ApiError, msg: str) -> JsonResponse:
3449
})
3550

3651

37-
def get_client_ip(request):
52+
def get_client_ip(request) -> str:
53+
"""
54+
Return the client IP.
55+
56+
This is a best effort way of fetching the client IP which does not protect
57+
against spoofing and hich tries to understand some proxying.
58+
59+
This should NOT be relied upon for serius stuff.
60+
"""
3861
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
3962
if x_forwarded_for:
4063
ip = x_forwarded_for.split(',')[0]
@@ -43,46 +66,83 @@ def get_client_ip(request):
4366
return ip
4467

4568

69+
def ensure_https_in_ops(fn):
70+
"""
71+
Ensure that the view is called via an HTTPS request and return a JSON error
72+
payload if not.
73+
74+
If DEBUG = True, it has no effect.
75+
"""
76+
@wraps(fn)
77+
def wrapper(request, *args, **kwargs):
78+
if not DEBUG and not request.is_secure():
79+
return _error(ApiError.WRONG_SCHEME, 'please use HTTPS')
80+
return fn(request, *args, **kwargs)
81+
return wrapper
82+
83+
84+
def ensure_post(fn):
85+
# We use this instead of the bult-in decorator to return a JSON error
86+
# payload instead of a simple 405.
87+
@wraps(fn)
88+
def wrapper(request, *args, **kwargs):
89+
if not request.method != 'POST':
90+
return _error(ApiError.WRONG_SCHEME, 'please use POST')
91+
return fn(request, *args, **kwargs)
92+
return wrapper
93+
94+
95+
def restrict_client_ip_to_allowed_list(fn):
96+
@wraps(fn)
97+
def wrapper(request, *args, **kwargs):
98+
# This is really a best effort attempt at detecting the client IP. It
99+
# does NOT handle IP spooding or any similar attack.
100+
best_effort_ip = get_client_ip(request)
101+
if ALLOWED_IPS and best_effort_ip not in ALLOWED_IPS:
102+
return _error(ApiError.UNAUTHORIZED, 'you are not authorized here')
103+
return fn(request, *args, **kwargs)
104+
return wrapper
105+
106+
46107
@csrf_exempt
108+
@ensure_post
109+
@ensure_https_in_ops
110+
@restrict_client_ip_to_allowed_list
47111
def isauth(request):
48112
"""
49113
Return whether or not the given email and password (sent via POST) are
50114
valid. If they are indeed valid, return the number and type of tickets
51-
assigned to the user.
115+
assigned to the user, together with some other user metadata (see below).
52116
53117
Input via POST:
54118
{
55-
"email": email,
56-
"password": password (not encrypted)
119+
"email": str,
120+
"password": str (not encrypted)
57121
}
122+
58123
Output (JSON)
59124
{
60-
"email": email,
61-
"first_name": first_name,
62-
"last_name": last_name,
63-
64-
"tickets": [{"fare_name": fare_name, "fare_code": fare_code}*]
125+
"username": str,
126+
"first_name": str,
127+
"last_name": str,
128+
"email": str,
129+
"is_staff": bool,
130+
"is_speaker": bool,
131+
"is_active": bool,
132+
"is_minor": bool,
133+
"tickets": [{"fare_name": str, "fare_code": str}*]
65134
}
66135
67136
Tickets, if any, are returned only for the currently active conference and
68-
only if ASSIGNED to email.
137+
only if ASSIGNED to the user identified by `email`.
69138
70-
If either email or password are incorrect/unknown, return
139+
In case of any error (including but not limited to if either email or
140+
password are incorrect/unknown), return
71141
{
72-
"message": "error message as string",
73-
"error": error_code
142+
"message": str,
143+
"error": int
74144
}
75145
"""
76-
best_effort_ip = get_client_ip(request)
77-
if ALLOWED_IPS and best_effort_ip not in ALLOWED_IPS:
78-
return _error(ApiError.UNAUTHORIZED, 'you are not authorized here')
79-
80-
if request.scheme != 'https':
81-
return _error(ApiError.WRONG_SCHEME, 'please use HTTPS')
82-
83-
if request.method != 'POST':
84-
return _error(ApiError.WRONG_METHOD, 'please use POST')
85-
86146
required_fields = {'email', 'password'}
87147

88148
try:
@@ -104,20 +164,14 @@ def isauth(request):
104164
if not check_password(data['password'], profile.user.password):
105165
return _error(ApiError.AUTH_ERROR, 'authentication error')
106166

107-
# Get the tickets
167+
# Get the tickets **assigned** to the user
108168
conference = Conference.objects.current()
169+
109170
tickets = Ticket.objects.filter(
110171
Q(fare__conference=conference.code)
111-
& Q(frozen=False)
112-
& Q(orderitem__order___complete=True)
113-
& Q(user=profile.user)
114-
).annotate(
115-
is_buyer=Case(
116-
When(orderitem__order__user__pk=profile.user.assopy_user.pk,
117-
then=Value(True)),
118-
default=Value(False),
119-
output_field=BooleanField(),
120-
)
172+
& Q(frozen=False) # i.e. the ticket was not cancelled
173+
& Q(orderitem__order___complete=True) # i.e. they paid
174+
& Q(user=profile.user) # i.e. assigned to user
121175
)
122176

123177
# A speaker is a user with at least one accepted talk in the current
@@ -148,6 +202,10 @@ def isauth(request):
148202
]
149203
}
150204

205+
# Just a little nice to have thing when debugging: we can send in the POST
206+
# payload, all the fields that we want to override in the answer and they
207+
# will just be passed through regardless of what is in the DB. We just
208+
# remove the password to be on the safe side.
151209
if DEBUG:
152210
data.pop('password')
153211
payload.update(data)

0 commit comments

Comments
 (0)