]>
git.ipfire.org Git - pbs.git/blob - src/web/base.py
15 import tornado
.websocket
18 from .. import __version__
19 from .. import builders
22 from ..decorators
import *
25 log
= logging
.getLogger("pbs.web.base")
27 class KerberosAuthMixin(object):
29 A mixin that handles Kerberos authentication
32 def kerberos_realm(self
):
36 def kerberos_service(self
):
37 return self
.settings
.get("krb5-service", "HTTP")
40 def kerberos_principal(self
):
41 return self
.settings
.get("krb5-principal", "pakfire/%s" % socket
.getfqdn())
43 def authenticate_redirect(self
):
45 Called when the application needs the user to authenticate.
47 We will send a response with status code 401 and set the
48 WWW-Authenticate header to ask the client to either initiate
49 some Kerberos authentication, or to perform HTTP Basic authentication.
51 # Ask the client to authenticate using Kerberos
52 self
.add_header("WWW-Authenticate", "Negotiate")
54 # Ask the client to authenticate using HTTP Basic Auth
55 self
.add_header("WWW-Authenticate", "Basic realm=\"%s\"" % self
.kerberos_realm
)
61 def get_authenticated_user(self
):
62 auth_header
= self
.request
.headers
.get("Authorization", None)
64 # No authentication header
68 # Perform GSS API Negotiation
69 if auth_header
.startswith("Negotiate "):
70 return self
._auth
_negotiate
(auth_header
)
72 # Perform Basic Authentication
73 elif auth_header
.startswith("Basic "):
74 return self
._auth
_basic
(auth_header
)
76 # Fail on anything else
78 raise tornado
.web
.HTTPError(400, "Unexpected Authentication attempt: %s" % auth_header
)
80 def _auth_negotiate(self
, auth_header
):
81 auth_value
= auth_header
.removeprefix("Negotiate ")
84 os
.environ
["KRB5_KTNAME"] = self
.backend
.settings
.get("krb5-keytab")
87 # Initialise the server session
88 result
, context
= kerberos
.authGSSServerInit(self
.kerberos_service
)
90 if not result
== kerberos
.AUTH_GSS_COMPLETE
:
91 raise tornado
.web
.HTTPError(500, "Kerberos Initialization failed: %s" % result
)
93 # Check the received authentication header
94 result
= kerberos
.authGSSServerStep(context
, auth_value
)
96 # If this was not successful, we will fall back to Basic authentication
97 if not result
== kerberos
.AUTH_GSS_COMPLETE
:
98 return self
._auth
_basic
(auth_header
)
100 if not isinstance(self
, tornado
.websocket
.WebSocketHandler
):
101 # Fetch the server response
102 response
= kerberos
.authGSSServerResponse(context
)
104 # Send the server response
105 self
.set_header("WWW-Authenticate", "Negotiate %s" % response
)
107 # Return the user who just authenticated
108 user
= kerberos
.authGSSServerUserName(context
)
110 except kerberos
.GSSError
as e
:
111 log
.error("Kerberos Authentication Error: %s" % e
)
113 raise tornado
.web
.HTTPError(500, "Could not initialize the Kerberos context")
117 kerberos
.authGSSServerClean(context
)
119 log
.debug("Successfully authenticated %s" % user
)
123 def _auth_basic(self
, auth_header
):
125 auth_header
= auth_header
.removeprefix("Basic ")
129 auth_header
= base64
.b64decode(auth_header
).decode()
131 username
, password
= auth_header
.split(":", 1)
133 raise tornado
.web
.HTTPError(400, "Authorization data was malformed")
135 # Authenticate against Kerberos
136 return self
._auth
_with
_credentials
(username
, password
)
138 def _auth_with_credentials(self
, username
, password
):
141 os
.environ
["KRB5_KTNAME"] = self
.backend
.settings
.get("krb5-keytab")
143 # Check the credentials against the Kerberos database
145 kerberos
.checkPassword(username
, password
,
146 self
.kerberos_principal
, self
.kerberos_realm
)
148 # Catch any authentication errors
149 except kerberos
.BasicAuthError
as e
:
150 log
.error("Could not authenticate %s: %s" % (username
, e
))
153 # Create user principal name
154 user
= "%s@%s" % (username
, self
.kerberos_realm
)
156 log
.debug("Successfully authenticated %s" % user
)
161 class BaseHandler(tornado
.web
.RequestHandler
):
164 return self
.application
.backend
168 return self
.backend
.db
172 # Get the session from the cookie
173 session_id
= self
.get_cookie("session_id", None)
175 # Search for a valid database session
177 return self
.backend
.sessions
.get(session_id
)
179 def get_current_user(self
):
181 return self
.session
.user
183 def get_user_locale(self
):
184 # Get the locale from the user settings
185 if self
.current_user
:
186 return self
.current_user
.locale
188 # If no locale was provided, we take what ever the browser requested
189 return self
.get_browser_locale()
192 def current_address(self
):
194 Returns the IP address the request came from.
196 return self
.request
.headers
.get("X-Real-IP") or self
.request
.remote_ip
199 def user_agent(self
):
201 Returns the HTTP user agent
203 return self
.request
.headers
.get("User-Agent", None)
205 def format_date(self
, date
, relative
=True, shorter
=False, full_format
=False):
206 return self
.locale
.format_date(date
, relative
=relative
,
207 shorter
=shorter
, full_format
=full_format
)
209 def get_template_namespace(self
):
210 ns
= tornado
.web
.RequestHandler
.get_template_namespace(self
)
213 "backend" : self
.backend
,
214 "hostname" : self
.request
.host
,
215 "format_date" : self
.format_date
,
216 "format_size" : misc
.format_size
,
217 "version" : __version__
,
218 "xsrf_token" : self
.xsrf_token
,
219 "year" : time
.strftime("%Y"),
224 def write_error(self
, code
, exc_info
=None, **kwargs
):
226 message
= http
.client
.responses
[code
]
232 # Collect more information about the exception if possible.
234 if self
.current_user
and isinstance(self
.current_user
, users
.User
):
235 if self
.current_user
.is_admin():
236 _traceback
+= traceback
.format_exception(*exc_info
)
238 self
.render("errors/error.html",
239 code
=code
, message
=message
, traceback
="".join(_traceback
), **kwargs
)
243 def get_argument_bool(self
, name
):
244 arg
= self
.get_argument(name
, default
=None)
247 return arg
.lower() in ("on", "true", "yes", "1")
251 def get_argument_int(self
, *args
, **kwargs
):
252 arg
= self
.get_argument(*args
, **kwargs
)
260 except (TypeError, ValueError):
261 raise tornado
.web
.HTTPError(400, "%s is not an integer" % arg
)
263 def get_argument_float(self
, *args
, **kwargs
):
264 arg
= self
.get_argument(*args
, **kwargs
)
272 except (TypeError, ValueError):
273 raise tornado
.web
.HTTPError(400, "%s is not an float" % arg
)
275 def get_argument_builder(self
, *args
, **kwargs
):
276 name
= self
.get_argument(*args
, **kwargs
)
279 return self
.backend
.builders
.get_by_name(name
)
281 def get_argument_distro(self
, *args
, **kwargs
):
282 slug
= self
.get_argument(*args
, **kwargs
)
285 return self
.backend
.distros
.get_by_slug(slug
)
289 def _get_upload(self
, uuid
):
290 upload
= self
.backend
.uploads
.get_by_uuid(uuid
)
293 if upload
and not upload
.has_perm(self
.current_user
):
294 raise tornado
.web
.HTTPError(403, "%s has no permissions for upload %s" % (self
.current_user
, upload
))
298 def get_argument_upload(self
, *args
, **kwargs
):
302 uuid
= self
.get_argument(*args
, **kwargs
)
305 return self
._get
_upload
(uuid
)
307 def get_argument_uploads(self
, *args
, **kwargs
):
309 Returns a list of uploads
311 uuids
= self
.get_arguments(*args
, **kwargs
)
314 return [self
._get
_upload
(uuid
) for uuid
in uuids
]
316 def get_argument_user(self
, *args
, **kwargs
):
317 name
= self
.get_argument(*args
, **kwargs
)
320 return self
.backend
.users
.get_by_name(name
)
323 BackendMixin
= BaseHandler
325 class APIError(tornado
.web
.HTTPError
):
327 Raised if there has been an error in the API
329 def __init__(self
, code
, message
):
330 super().__init
__(400, message
)
333 self
.message
= message
339 class APIMixin(KerberosAuthMixin
, BackendMixin
):
340 # Generally do not permit users to authenticate against the API
343 # Do not perform any XSRF cookie validation on API calls
344 def check_xsrf_cookie(self
):
347 def get_current_user(self
):
349 Authenticates a user or builder
351 # Fetch the Kerberos ticket
352 principal
= self
.get_authenticated_user()
354 # Return nothing if we did not receive any credentials
358 logging
.debug("Searching for principal %s..." % principal
)
361 principal
, delimiter
, realm
= principal
.partition("@")
363 # Return any builders
364 if principal
.startswith("host/"):
365 hostname
= principal
.removeprefix("host/")
367 return self
.backend
.builders
.get_by_name(hostname
)
369 # End here if users are not allowed to authenticate
370 if not self
.allow_users
:
374 return self
.backend
.users
.get_by_name(principal
)
376 def get_user_locale(self
):
377 return self
.get_browser_locale()
382 This is a convenience handler to access a builder by a better name
384 if isinstance(self
.current_user
, builders
.Builder
):
385 return self
.current_user
389 def get_compression_options(self
):
390 # Enable maximum compression
392 "compression_level" : 9,
396 def write_error(self
, code
, exc_info
):
398 Sends a JSON-encoded error message
400 type, error
, traceback
= exc_info
402 # We only handle API errors here
403 if not isinstance(error
, APIError
):
404 return super().write_error(code
, exc_info
)
406 # We send errors as 200
412 "message" : error
.message
,
416 def _decode_json_message(self
, message
):
417 # Decode JSON message
419 message
= json
.loads(message
)
421 except json
.DecodeError
as e
:
422 log
.error("Could not decode JSON message", exc_info
=True)
426 log
.debug("Received message:")
427 log
.debug("%s" % json
.dumps(message
, indent
=4))
432 def negotiate(method
):
434 Requires clients to use SPNEGO
436 @functools.wraps(method
)
437 def wrapper(self
, *args
, **kwargs
):
438 if not self
.current_user
:
439 # Send the Negotiate header
440 self
.add_header("WWW-Authenticate", "Negotiate")
448 return method(self
, *args
, **kwargs
)
452 class ratelimit(object):
454 A decorator class which limits how often a function can be called
456 def __init__(self
, *, minutes
, requests
):
457 self
.minutes
= minutes
458 self
.requests
= requests
460 def __call__(self
, method
):
461 @functools.wraps(method
)
462 async def wrapper(handler
, *args
, **kwargs
):
463 # Pass the request to the rate limiter and get a request object
464 req
= handler
.backend
.ratelimiter
.handle_request(handler
.request
,
465 handler
, minutes
=self
.minutes
, limit
=self
.requests
)
467 # If the rate limit has been reached, we won't allow
468 # processing the request and therefore send HTTP error code 429.
469 if await req
.is_ratelimited():
470 raise tornado
.web
.HTTPError(429, "Rate limit exceeded")
472 # Call the wrapped method
473 result
= method(handler
, *args
, **kwargs
)
475 # Await it if it is a coroutine
476 if asyncio
.iscoroutine(result
):