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
+ """
1
15
from enum import Enum
2
16
import json
17
+ from functools import wraps
3
18
from django .conf .urls import url as re_path
4
19
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
6
21
from django .http import JsonResponse
7
22
from django .views .decorators .csrf import csrf_exempt
8
23
from conference .models import (
@@ -34,7 +49,15 @@ def _error(error: ApiError, msg: str) -> JsonResponse:
34
49
})
35
50
36
51
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
+ """
38
61
x_forwarded_for = request .META .get ('HTTP_X_FORWARDED_FOR' )
39
62
if x_forwarded_for :
40
63
ip = x_forwarded_for .split (',' )[0 ]
@@ -43,46 +66,83 @@ def get_client_ip(request):
43
66
return ip
44
67
45
68
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
+
46
107
@csrf_exempt
108
+ @ensure_post
109
+ @ensure_https_in_ops
110
+ @restrict_client_ip_to_allowed_list
47
111
def isauth (request ):
48
112
"""
49
113
Return whether or not the given email and password (sent via POST) are
50
114
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) .
52
116
53
117
Input via POST:
54
118
{
55
- "email": email ,
56
- "password": password (not encrypted)
119
+ "email": str ,
120
+ "password": str (not encrypted)
57
121
}
122
+
58
123
Output (JSON)
59
124
{
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}*]
65
134
}
66
135
67
136
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` .
69
138
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
71
141
{
72
- "message": "error message as string" ,
73
- "error": error_code
142
+ "message": str ,
143
+ "error": int
74
144
}
75
145
"""
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
-
86
146
required_fields = {'email' , 'password' }
87
147
88
148
try :
@@ -104,20 +164,14 @@ def isauth(request):
104
164
if not check_password (data ['password' ], profile .user .password ):
105
165
return _error (ApiError .AUTH_ERROR , 'authentication error' )
106
166
107
- # Get the tickets
167
+ # Get the tickets **assigned** to the user
108
168
conference = Conference .objects .current ()
169
+
109
170
tickets = Ticket .objects .filter (
110
171
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
121
175
)
122
176
123
177
# A speaker is a user with at least one accepted talk in the current
@@ -148,6 +202,10 @@ def isauth(request):
148
202
]
149
203
}
150
204
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.
151
209
if DEBUG :
152
210
data .pop ('password' )
153
211
payload .update (data )
0 commit comments