1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370
|
#!/usr/bin/env python2 -tt
# -*- coding: utf-8 -*-
#
# Copyright (C) 2013-2015 Red Hat, Inc.
# This file is part of python-fedora
#
# python-fedora is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# python-fedora is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with python-fedora; if not, see <http://www.gnu.org/licenses/>
#
"""Base client for application relying on OpenID for authentication.
.. moduleauthor:: Pierre-Yves Chibon <pingou@fedoraproject.org>
.. moduleauthor:: Toshio Kuratomi <toshio@fedoraproject.org>
.. moduleauthor:: Ralph Bean <rbean@redhat.com>
.. versionadded: 0.3.35
"""
# :F0401: Unable to import : Disabled because these will either import on py3
# or py2 not both.
# :E0611: No name $X in module: This was renamed in python3
import json
import logging
import os
import filelock
import requests
import requests.adapters
from requests.packages.urllib3.util import Retry
from six.moves.urllib.parse import urljoin
from functools import wraps
from munch import munchify
from kitchen.text.converters import to_bytes
from fedora import __version__
from fedora.client import (AuthError,
LoginRequiredError,
ServerError,
UnsafeFileError,
check_file_permissions)
from fedora.client.openidproxyclient import (
OpenIdProxyClient, absolute_url, openid_login)
log = logging.getLogger(__name__)
b_SESSION_DIR = os.path.join(os.path.expanduser('~'), '.fedora')
b_SESSION_FILE = os.path.join(b_SESSION_DIR, 'openidbaseclient-sessions.cache')
def requires_login(func):
"""
Decorator function for get or post requests requiring login.
Decorate a controller method that requires the user to be authenticated.
Example::
from fedora.client.openidbaseclient import requires_login
@requires_login
def rename_user(new_name):
user = new_name
# [...]
"""
def _decorator(request, *args, **kwargs):
""" Run the function and check if it redirected to the openid form.
Or if we got a 403
"""
output = func(request, *args, **kwargs)
if output and \
'<title>OpenID transaction in progress</title>' in output.text:
raise LoginRequiredError(
'{0} requires a logged in user'.format(output.url))
elif output.status_code == 403:
raise LoginRequiredError(
'{0} requires a logged in user'.format(output.url))
return output
return wraps(func)(_decorator)
class OpenIdBaseClient(OpenIdProxyClient):
""" A client for interacting with web services relying on openid auth. """
def __init__(self, base_url, login_url=None, useragent=None, debug=False,
insecure=False, openid_insecure=False, username=None,
cache_session=True, retries=None, timeout=None,
retry_backoff_factor=0):
"""Client for interacting with web services relying on fas_openid auth.
:arg base_url: Base of every URL used to contact the server
:kwarg login_url: The url to the login endpoint of the application.
If none are specified, it uses the default `/login`.
:kwarg useragent: Useragent string to use. If not given, default to
"Fedora OpenIdBaseClient/VERSION"
:kwarg debug: If True, log debug information
:kwarg insecure: If True, do not check server certificates against
their CA's. This means that man-in-the-middle attacks are
possible against the `BaseClient`. You might turn this option on
for testing against a local version of a server with a self-signed
certificate but it should be off in production.
:kwarg openid_insecure: If True, do not check the openid server
certificates against their CA's. This means that man-in-the-
middle attacks are possible against the `BaseClient`. You might
turn this option on for testing against a local version of a
server with a self-signed certificate but it should be off in
production.
:kwarg username: Username for establishing authenticated connections
:kwarg cache_session: If set to true, cache the user's session data on
the filesystem between runs
:kwarg retries: if we get an unknown or possibly transient error from
the server, retry this many times. Setting this to a negative
number makes it try forever. Defaults to zero, no retries.
Note that this can only be set during object initialization.
:kwarg timeout: A float describing the timeout of the connection. The
timeout only affects the connection process itself, not the
downloading of the response body. Defaults to 120 seconds.
:kwarg retry_backoff_factor: Exponential backoff factor to apply in
between retry attempts. We will sleep for:
`{retry_backoff_factor}*(2 ^ ({number of failed retries} - 1))`
...seconds inbetween attempts. The backoff factor scales the rate
at which we back off. Defaults to 0 (backoff disabled).
Note that this attribute can only be set at object initialization.
"""
# These are also needed by OpenIdProxyClient
self.useragent = useragent or 'Fedora BaseClient/%(version)s' % {
'version': __version__}
self.base_url = base_url
self.login_url = login_url or urljoin(self.base_url, '/login')
self.debug = debug
self.insecure = insecure
self.openid_insecure = openid_insecure
self.retries = retries
self.timeout = timeout
# These are specific to OpenIdBaseClient
self.username = username
self.cache_session = cache_session
self.cache_lock = filelock.FileLock(b_SESSION_FILE + '.lock')
# Make sure the database for storing the session cookies exists
if cache_session:
self._initialize_session_cache()
# python-requests session. Holds onto cookies
self._session = requests.session()
# Also hold on to retry logic.
# http://www.coglib.com/~icordasc/blog/2014/12/retries-in-requests.html
server_errors = [500, 501, 502, 503, 504, 506, 507, 508, 509, 599]
method_whitelist = Retry.DEFAULT_ALLOWED_METHODS.union(set(['POST']))
if retries is not None:
prefixes = ['http://', 'https://']
for prefix in prefixes:
self._session.mount(prefix, requests.adapters.HTTPAdapter(
max_retries=Retry(
total=retries,
status_forcelist=server_errors,
backoff_factor=retry_backoff_factor,
method_whitelist=method_whitelist,
),
))
# See if we have any cookies kicking around from a previous run
self._load_cookies()
def _initialize_session_cache(self):
# Note -- fallback to returning None on any problems as this isn't
# critical. It just makes it so that we don't have to ask the user
# for their password over and over.
if not os.path.isdir(b_SESSION_DIR):
try:
os.makedirs(b_SESSION_DIR, mode=0o750)
except OSError as err:
log.warning('Unable to create {file}: {error}'.format(
file=b_SESSION_DIR, error=err))
self.cache_session = False
return None
@requires_login
def _authed_post(self, url, params=None, data=None, **kwargs):
""" Return the request object of a post query."""
response = self._session.post(url, params=params, data=data, **kwargs)
return response
@requires_login
def _authed_get(self, url, params=None, data=None, **kwargs):
""" Return the request object of a get query."""
response = self._session.get(url, params=params, data=data, **kwargs)
return response
@requires_login
def _authed_put(self, url, params=None, data=None, **kwargs):
""" Return the request object of a put query."""
response = self._session.put(url, params=params, data=data, **kwargs)
return response
@requires_login
def _authed_delete(self, url, params=None, data=None, **kwargs):
""" Return the request object of a delete query."""
response = self._session.delete(url, params=params, data=data, **kwargs)
return response
def send_request(self, method, auth=False, verb='POST', **kwargs):
"""Make an HTTP request to a server method.
The given method is called with any parameters set in req_params. If
auth is True, then the request is made with an authenticated session
cookie.
:arg method: Method to call on the server. It's a url fragment that
comes after the :attr:`base_url` set in :meth:`__init__`.
:kwarg auth: If True perform auth to the server, else do not.
:kwarg req_params: Extra parameters to send to the server.
:kwarg file_params: dict of files where the key is the name of the
file field used in the remote method and the value is the local
path of the file to be uploaded. If you want to pass multiple
files to a single file field, pass the paths as a list of paths.
:kwarg verb: HTTP verb to use. GET and POST are currently supported.
POST is the default.
"""
# Decide on the set of auth cookies to use
method = absolute_url(self.base_url, method)
self._authed_verb_dispatcher = {(False, 'POST'): self._session.post,
(False, 'GET'): self._session.get,
(False, 'PUT'): self._session.put,
(False, 'DELETE'): self._session.delete,
(True, 'POST'): self._authed_post,
(True, 'GET'): self._authed_get,
(True, 'PUT'): self._authed_put,
(True, 'DELETE'): self._authed_delete}
if 'timeout' not in kwargs:
kwargs['timeout'] = self.timeout
try:
func = self._authed_verb_dispatcher[(auth, verb)]
except KeyError:
raise Exception('Unknown HTTP verb')
try:
output = func(method, **kwargs)
except LoginRequiredError:
raise AuthError()
try:
data = output.json()
except ValueError as e:
# The response wasn't JSON data
raise ServerError(
method, output.status_code, 'Error returned from'
' json module while processing %(url)s: %(err)s\n%(output)s' %
{
'url': to_bytes(method),
'err': to_bytes(e),
'output': to_bytes(output.text),
})
data = munchify(data)
return data
def login(self, username, password, otp=None):
""" Open a session for the user.
Log in the user with the specified username and password
against the FAS OpenID server.
:arg username: the FAS username of the user that wants to log in
:arg password: the FAS password of the user that wants to log in
:kwarg otp: currently unused. Eventually a way to send an otp to the
API that the API can use.
"""
if not username:
raise AuthError("Username may not be %r at login." % username)
if not password:
raise AuthError("Password required for login.")
# It looks like we're really doing this. Let's make sure that we don't
# collide various cookies, and clear every cookie we had up until now
# for this service.
self._session.cookies.clear()
self._save_cookies()
response = openid_login(
session=self._session,
login_url=self.login_url,
username=username,
password=password,
otp=otp,
openid_insecure=self.openid_insecure)
self._save_cookies()
return response
@property
def session_key(self):
return "%s:%s" % (self.base_url, self.username or '')
def has_cookies(self):
return bool(self._session.cookies)
def _load_cookies(self):
if not self.cache_session:
return
try:
check_file_permissions(b_SESSION_FILE, True)
except UnsafeFileError as e:
log.debug('Current sessions ignored: {}'.format(str(e)))
return
try:
with self.cache_lock:
with open(b_SESSION_FILE, 'rb') as f:
data = json.loads(f.read().decode('utf-8'))
for key, value in data[self.session_key]:
self._session.cookies[key] = value
except KeyError:
log.debug("No pre-existing session for %s" % self.session_key)
except IOError:
# The file doesn't exist, so create it.
log.debug("Creating %s", b_SESSION_FILE)
oldmask = os.umask(0o027)
with open(b_SESSION_FILE, 'wb') as f:
f.write(json.dumps({}).encode('utf-8'))
os.umask(oldmask)
def _save_cookies(self):
if not self.cache_session:
return
with self.cache_lock:
try:
check_file_permissions(b_SESSION_FILE, True)
with open(b_SESSION_FILE, 'rb') as f:
data = json.loads(f.read().decode('utf-8'))
except UnsafeFileError as e:
log.debug('Clearing sessions: {}'.format(str(e)))
os.unlink(b_SESSION_FILE)
data = {}
except Exception:
log.warn("Failed to open cookie cache before saving.")
data = {}
oldmask = os.umask(0o027)
data[self.session_key] = self._session.cookies.items()
with open(b_SESSION_FILE, 'wb') as f:
f.write(json.dumps(data).encode('utf-8'))
os.umask(oldmask)
__all__ = ('OpenIdBaseClient', 'requires_login')
|