]> git.ipfire.org Git - pbs.git/blame - src/web/base.py
auth: Revert back to authentication using a web form
[pbs.git] / src / web / base.py
CommitLineData
9137135a
MT
1#!/usr/bin/python
2
4f1cef10 3import asyncio
f062b044 4import base64
4f1cef10 5import functools
15a45292 6import http.client
f062b044
MT
7import json
8import kerberos
9import logging
10import os
9137135a
MT
11import time
12import tornado.locale
13import tornado.web
f062b044 14import tornado.websocket
f6e6ff79 15import traceback
9137135a 16
14d7ed77 17from .. import __version__
61428bce 18from .. import builders
2c909128 19from .. import misc
f062b044 20from .. import users
d2738057 21from ..decorators import *
9137135a 22
f062b044 23# Setup logging
6acc7746 24log = logging.getLogger("pbs.web.base")
f062b044
MT
25
26class KerberosAuthMixin(object):
27 """
28 A mixin that handles Kerberos authentication
29 """
30 @property
31 def kerberos_realm(self):
32 return "IPFIRE.ORG"
33
34 @property
35 def kerberos_service(self):
36 return self.settings.get("kerberos_service", "HTTP")
37
38 def authenticate_redirect(self):
39 """
40 Called when the application needs the user to authenticate.
41
42 We will send a response with status code 401 and set the
43 WWW-Authenticate header to ask the client to either initiate
44 some Kerberos authentication, or to perform HTTP Basic authentication.
45 """
46 # Ask the client to authenticate using Kerberos
47 self.add_header("WWW-Authenticate", "Negotiate")
48
49 # Ask the client to authenticate using HTTP Basic Auth
50 self.add_header("WWW-Authenticate", "Basic realm=\"%s\"" % self.kerberos_realm)
51
52 # Set status to 401
53 self.set_status(401)
54
4e564583 55 @functools.cache
f062b044
MT
56 def get_authenticated_user(self):
57 auth_header = self.request.headers.get("Authorization", None)
58
59 # No authentication header
60 if not auth_header:
61 return
62
63 # Perform GSS API Negotiation
216a565a 64 if auth_header.startswith("Negotiate "):
f062b044
MT
65 return self._auth_negotiate(auth_header)
66
67 # Perform Basic Authentication
68 elif auth_header.startswith("Basic "):
69 return self._auth_basic(auth_header)
70
71 # Fail on anything else
72 else:
73 raise tornado.web.HTTPError(400, "Unexpected Authentication attempt: %s" % auth_header)
74
75 def _auth_negotiate(self, auth_header):
76 os.environ["KRB5_KTNAME"] = self.backend.settings.get("krb5-keytab")
77
78 auth_value = auth_header.removeprefix("Negotiate ")
79
80 try:
81 # Initialise the server session
82 result, context = kerberos.authGSSServerInit(self.kerberos_service)
83
84 if not result == kerberos.AUTH_GSS_COMPLETE:
85 raise tornado.web.HTTPError(500, "Kerberos Initialization failed: %s" % result)
86
87 # Check the received authentication header
88 result = kerberos.authGSSServerStep(context, auth_value)
89
90 # If this was not successful, we will fall back to Basic authentication
91 if not result == kerberos.AUTH_GSS_COMPLETE:
92 return self._auth_basic(auth_header)
93
94 if not isinstance(self, tornado.websocket.WebSocketHandler):
95 # Fetch the server response
96 response = kerberos.authGSSServerResponse(context)
97
98 # Send the server response
99 self.set_header("WWW-Authenticate", "Negotiate %s" % response)
100
101 # Return the user who just authenticated
102 user = kerberos.authGSSServerUserName(context)
103
104 except kerberos.GSSError as e:
105 log.error("Kerberos Authentication Error: %s" % e)
106
107 raise tornado.web.HTTPError(500, "Could not initialize the Kerberos context")
108
109 finally:
110 # Cleanup
111 kerberos.authGSSServerClean(context)
112
113 log.debug("Successfully authenticated %s" % user)
114
115 return user
116
117 def _auth_basic(self, auth_header):
118 os.environ["KRB5_KTNAME"] = self.backend.settings.get("krb5-keytab")
119
120 # Remove "Basic "
121 auth_header = auth_header.removeprefix("Basic ")
122
123 try:
124 # Decode base64
125 auth_header = base64.b64decode(auth_header).decode()
126
127 username, password = auth_header.split(":", 1)
128 except:
129 raise tornado.web.HTTPError(400, "Authorization data was malformed")
130
216a565a
MT
131 # Authenticate against Kerberos
132 return self._auth_with_credentials(username, password)
133
134 def _auth_with_credentials(self, username, password):
f062b044
MT
135 # Check the credentials against the Kerberos database
136 try:
137 kerberos.checkPassword(username, password,
138 "%s/pakfire.ipfire.org" % self.kerberos_service, self.kerberos_realm)
139
140 # Catch any authentication errors
141 except kerberos.BasicAuthError as e:
142 log.error("Could not authenticate %s: %s" % (username, e))
143 return
144
145 # Create user principal name
146 user = "%s@%s" % (username, self.kerberos_realm)
147
148 log.debug("Successfully authenticated %s" % user)
149
150 return user
151
152
9137135a 153class BaseHandler(tornado.web.RequestHandler):
c6c94528
MT
154 @property
155 def backend(self):
156 return self.application.backend
157
158 @property
159 def db(self):
160 return self.backend.db
161
d2738057
MT
162 @lazy_property
163 def session(self):
164 # Get the session from the cookie
165 session_id = self.get_cookie("session_id", None)
f6e6ff79 166
d2738057
MT
167 # Search for a valid database session
168 if session_id:
72568c46 169 return self.backend.sessions.get(session_id)
9137135a 170
d2738057
MT
171 def get_current_user(self):
172 if self.session:
70987fab 173 return self.session.user
bdeb3c62 174
9137135a 175 def get_user_locale(self):
4947da2d
MT
176 # Get the locale from the user settings
177 if self.current_user:
178 return self.current_user.locale
9137135a 179
4947da2d 180 # If no locale was provided, we take what ever the browser requested
214463e3 181 return self.get_browser_locale()
9137135a 182
f6e6ff79 183 @property
12d79e59 184 def current_address(self):
f6e6ff79
MT
185 """
186 Returns the IP address the request came from.
187 """
371a879e 188 return self.request.headers.get("X-Real-IP") or self.request.remote_ip
f6e6ff79 189
61c74986
MT
190 @property
191 def user_agent(self):
192 """
193 Returns the HTTP user agent
194 """
195 return self.request.headers.get("User-Agent", None)
196
6468877e
MT
197 def format_date(self, date, relative=True, shorter=False, full_format=False):
198 return self.locale.format_date(date, relative=relative,
199 shorter=shorter, full_format=full_format)
f6e6ff79 200
8fa68307
MT
201 def get_template_namespace(self):
202 ns = tornado.web.RequestHandler.get_template_namespace(self)
203
204 ns.update({
fe9b927c 205 "backend" : self.backend,
f6e6ff79
MT
206 "hostname" : self.request.host,
207 "format_date" : self.format_date,
2c909128 208 "format_size" : misc.format_size,
14d7ed77 209 "version" : __version__,
d3307cb1 210 "xsrf_token" : self.xsrf_token,
f6e6ff79 211 "year" : time.strftime("%Y"),
8fa68307 212 })
9137135a 213
8fa68307 214 return ns
9137135a 215
7ec8e0d3 216 def write_error(self, code, exc_info=None, **kwargs):
703e79ed 217 try:
7ec8e0d3 218 message = http.client.responses[code]
703e79ed 219 except KeyError:
7ec8e0d3
MT
220 message = None
221
222 _traceback = []
f6e6ff79
MT
223
224 # Collect more information about the exception if possible.
703e79ed 225 if exc_info:
f062b044
MT
226 if self.current_user and isinstance(self.current_user, users.User):
227 if self.current_user.is_admin():
228 _traceback += traceback.format_exception(*exc_info)
f6e6ff79 229
7ec8e0d3
MT
230 self.render("errors/error.html",
231 code=code, message=message, traceback="".join(_traceback), **kwargs)
1af6c96c
MT
232
233 # Typed Arguments
234
0bcebf53
MT
235 def get_argument_bool(self, name):
236 arg = self.get_argument(name, default=None)
237
f791a598
MT
238 if arg:
239 return arg.lower() in ("on", "true", "yes", "1")
240
241 return False
0bcebf53 242
1af6c96c
MT
243 def get_argument_int(self, *args, **kwargs):
244 arg = self.get_argument(*args, **kwargs)
245
0bcebf53
MT
246 # Return nothing
247 if not arg:
248 return None
249
1af6c96c
MT
250 try:
251 return int(arg)
252 except (TypeError, ValueError):
253 raise tornado.web.HTTPError(400, "%s is not an integer" % arg)
98ee6714 254
bff2ca64
MT
255 def get_argument_builder(self, *args, **kwargs):
256 name = self.get_argument(*args, **kwargs)
257
258 if name:
259 return self.backend.builders.get_by_name(name)
260
98ee6714
MT
261 def get_argument_distro(self, *args, **kwargs):
262 slug = self.get_argument(*args, **kwargs)
263
264 if slug:
265 return self.backend.distros.get_by_slug(slug)
f004aee7 266
5c6b5928
MT
267 # Uploads
268
269 def _get_upload(self, uuid):
270 upload = self.backend.uploads.get_by_uuid(uuid)
271
272 # Check permissions
273 if upload and not upload.has_perm(self.current_user):
274 raise tornado.web.HTTPError(403, "%s has no permissions for upload %s" % (self.current_user, upload))
275
276 return upload
277
f062b044
MT
278 def get_argument_upload(self, *args, **kwargs):
279 """
280 Returns an upload
281 """
282 uuid = self.get_argument(*args, **kwargs)
283
284 if uuid:
5c6b5928 285 return self._get_upload(uuid)
f062b044
MT
286
287 def get_argument_uploads(self, *args, **kwargs):
288 """
289 Returns a list of uploads
290 """
291 uuids = self.get_arguments(*args, **kwargs)
292
293 # Return all uploads
5c6b5928 294 return [self._get_upload(uuid) for uuid in uuids]
f062b044 295
f004aee7
MT
296 def get_argument_user(self, *args, **kwargs):
297 name = self.get_argument(*args, **kwargs)
298
299 if name:
300 return self.backend.users.get_by_name(name)
f062b044
MT
301
302# XXX TODO
303BackendMixin = BaseHandler
304
305class APIMixin(KerberosAuthMixin, BackendMixin):
306 # Generally do not permit users to authenticate against the API
307 allow_users = False
308
309 # Do not perform any XSRF cookie validation on API calls
310 def check_xsrf_cookie(self):
311 pass
312
313 def get_current_user(self):
314 """
315 Authenticates a user or builder
316 """
317 # Fetch the Kerberos ticket
318 principal = self.get_authenticated_user()
319
320 # Return nothing if we did not receive any credentials
321 if not principal:
322 return
323
324 logging.debug("Searching for principal %s..." % principal)
325
326 # Strip the realm
327 principal, delimiter, realm = principal.partition("@")
328
329 # Return any builders
330 if principal.startswith("host/"):
331 hostname = principal.removeprefix("host/")
332
333 return self.backend.builders.get_by_name(hostname)
334
335 # End here if users are not allowed to authenticate
336 if not self.allow_users:
337 return
338
339 # Return users
340 return self.backend.users.get_by_name(principal)
341
342 def get_user_locale(self):
343 return self.get_browser_locale()
344
46a7bd16
MT
345 @property
346 def builder(self):
347 """
348 This is a convenience handler to access a builder by a better name
349 """
350 if isinstance(self.current_user, builders.Builder):
351 return self.current_user
352
353 raise AttributeError
354
1a1584ce
MT
355 def get_compression_options(self):
356 # Enable maximum compression
357 return {
358 "compression_level" : 9,
359 "mem_level" : 9,
360 }
361
f062b044
MT
362 def write_error(self, code, **kwargs):
363 # Send a JSON-encoded error message
364 self.finish({
365 "error" : True,
366 # XXX add error string
367 })
368
369 def _decode_json_message(self, message):
370 # Decode JSON message
371 try:
372 message = json.loads(message)
373
374 except json.DecodeError as e:
375 log.error("Could not decode JSON message", exc_info=True)
376 raise e
377
378 # Log message
379 log.debug("Received message:")
380 log.debug("%s" % json.dumps(message, indent=4))
381
382 return message
4f1cef10
MT
383
384
385class ratelimit(object):
386 """
387 A decorator class which limits how often a function can be called
388 """
389 def __init__(self, *, minutes, requests):
390 self.minutes = minutes
391 self.requests = requests
392
393 def __call__(self, method):
394 @functools.wraps(method)
395 async def wrapper(handler, *args, **kwargs):
396 # Pass the request to the rate limiter and get a request object
397 req = handler.backend.ratelimiter.handle_request(handler.request,
398 handler, minutes=self.minutes, limit=self.requests)
399
400 # If the rate limit has been reached, we won't allow
401 # processing the request and therefore send HTTP error code 429.
402 if await req.is_ratelimited():
403 raise tornado.web.HTTPError(429, "Rate limit exceeded")
404
405 # Call the wrapped method
406 result = method(handler, *args, **kwargs)
407
408 # Await it if it is a coroutine
409 if asyncio.iscoroutine(result):
410 return await result
411
412 # Return the result
413 return result
414
415 return wrapper