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+ """
115from enum import Enum
216import json
17+ from functools import wraps
318from django .conf .urls import url as re_path
419from 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
621from django .http import JsonResponse
722from django .views .decorators .csrf import csrf_exempt
823from 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
47111def 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