diff --git a/roles/blockerbugs/files/python-fedora-openidbaseclient-hotfix.py b/roles/blockerbugs/files/python-fedora-openidbaseclient-hotfix.py new file mode 100644 index 0000000000..5bc1c8af14 --- /dev/null +++ b/roles/blockerbugs/files/python-fedora-openidbaseclient-hotfix.py @@ -0,0 +1,406 @@ +#!/usr/bin/env python2 -tt +# -*- coding: utf-8 -*- +# +# Copyright (C) 2013-2014 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 +# + +"""Base client for application relying on OpenID for authentication. + +.. moduleauthor:: Pierre-Yves Chibon +.. moduleauthor:: Toshio Kuratomi + +.. 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 logging +import os +import sqlite3 +from collections import defaultdict +from functools import partial + +# pylint: disable-msg=F0401 +try: + # pylint: disable-msg=E0611 + # Python 3 + from urllib.parse import urljoin +except ImportError: + # Python 2 + from urlparse import urljoin +# pylint: enable-msg=F0401,E0611 + + +import requests +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 +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, 'baseclient-sessions.sqlite') + + +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. + """ + output = func(request, *args, **kwargs) + if output and \ + 'OpenID transaction in progress' in output.text: + 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, + session_id=None, session_name='session', + openid_session_id=None, openid_session_name='FAS_OPENID', + cache_session=True, retries=None, timeout=None): + """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 session_id: id of the user's session + :kwarg session_name: name of the cookie to use with session handling + :kwarg openid_session_id: id of the user's openid session + :kwarg openid_session_name: name of the cookie to use with openid + session handling + :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. + :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. + + """ + + # 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 + self.session_name = session_name + self.openid_session_name = openid_session_name + + # These are specific to OpenIdBaseClient + self.username = username + self.cache_session = cache_session + + # Make sure the database for storing the session cookies exists + if cache_session: + self._db = self._initialize_session_cache() + if not self._db: + self.cache_session = False + + # Session cookie that identifies this user to the application + self._session_id_map = defaultdict(str) + if session_id: + self.session_id = session_id + if openid_session_id: + self.openid_session_id = openid_session_id + + # python-requests session. Holds onto cookies + self._session = requests.session() + + 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=0o755) + except OSError as err: + log.warning('Unable to create {file}: {error}'.format( + file=b_SESSION_DIR, error=err)) + return None + + if not os.path.exists(b_SESSION_FILE): + dbcon = sqlite3.connect(b_SESSION_FILE) + cursor = dbcon.cursor() + try: + cursor.execute('create table sessions (username text,' + ' base_url text, session_id text,' + ' primary key (username, base_url))') + except sqlite3.DatabaseError as err: + # Probably not a database + log.warning('Unable to initialize {file}: {error}'.format( + file=b_SESSION_FILE, error=err)) + return None + dbcon.commit() + else: + try: + dbcon = sqlite3.connect(b_SESSION_FILE) + except sqlite3.OperationalError as err: + # Likely permission denied + log.warning('Unable to connect to {file}: {error}'.format( + file=b_SESSION_FILE, error=err)) + return None + return dbcon + + def _get_id(self, base_url=None): + # base_url is only sent as a param if we're looking for the openid + # session + base_url = base_url or self.base_url + + username = self.username or '' + + session_id_key = '{}:{}'.format(base_url, username) + if self._session_id_map[session_id_key]: + # Cached in memory + return self._session_id_map[session_id_key] + + if username and self.cache_session: + # Look for a session on disk + cursor = self._db.cursor() + cursor.execute('select * from sessions where' + ' username = ? and base_url = ?', + (username, base_url)) + found_sessions = cursor.fetchall() + if found_sessions: + self._session_id_map[session_id_key] = found_sessions[0] + + if not self._session_id_map[session_id_key]: + log.debug('No session cached for "{username}"'.format( + username=self.username)) + return self._session_id_map[session_id_key] + + def _set_id(self, session_id, base_url=None): + #if not self.username: + # base_url is only sent as a param if we're looking for the openid + # session + base_url = base_url or self.base_url + + username = self.username or '' + + # Cache in memory + session_id_key = '{}:{}'.format(base_url, username) + self._session_id_map[session_id_key] = session_id + + if username and self.cache_session: + # Save to disk as well + cursor = self._db.cursor() + try: + cursor.exectue('insert into sessions' + ' (session_id, username, base_url)' + ' values (?, ?, ?)', + (session_id, username, base_url)) + except sqlite3.IntegrityError: + # Record already exists for that username and url + cursor.execute('update sessions set session_id = ? where' + ' username = ? and base_url = ?', + (session_id, username, base_url)) + cursor.commit() + + def _del_id(self, base_url=None): + # base_url is only sent as a param if we're looking for the openid + # session + base_url = base_url or self.base_url + + username = self.username or '' + + # Remove from the in-memory cache + session_id_key = '{}:{}'.format(base_url, username) + del self._session_id_map[session_id_key] + + if username and self.cache_session: + # Remove from the disk cache as well + cursor = self._db.cursor() + cursor.execute('delete from sessions where' + ' username = ? and base_url = ?', + (username, base_url)) + + session_id = property( + _get_id, + _set_id, + _del_id, + """The session_id. + + The session id is saved in a file in case it is needed in + consecutive runs of BaseClient. + """) + + openid_session_id = property( + partial(_get_id, base_url='FAS_OPENID'), + partial(_set_id, base_url='FAS_OPENID'), + partial(_del_id, base_url='FAS_OPENID'), + """The openid_session_id. + + The openid session id is saved in a file in case it is needed in + consecutive runs of BaseClient. + """) + + @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 + + 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 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. Default to use the :attr:`retries` + value set on the instance or in :meth:`__init__` (which defaults + to zero, no retries). + :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. Default to use the + :attr:`timeout` value set on the instance or in :meth:`__init__` + (which defaults to 120s). + :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, + (True, 'POST'): self._authed_post, + (True, 'GET'): self._authed_get} + try: + func = self._authed_verb_dispatcher[(auth, verb)] + except KeyError: + raise Exception('Unknown HTTP verb') + + if auth: + auth_params = {'session_id': self.session_id, + 'openid_session_id': self.openid_session_id} + try: + output = func(method, auth_params, **kwargs) + except LoginRequiredError: + raise AuthError() + else: + try: + output = func(method, **kwargs) + except LoginRequiredError: + raise AuthError() + + try: + data = output.json + # Compatibility with newer python-requests + if callable(data): + data = data() + 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. + + """ + response = openid_login( + session=self._session, + login_url=self.login_url, + username=username, + password=password, + otp=otp, + openid_insecure=self.openid_insecure) + return response + +__all__ = ('OpenIdBaseClient', 'requires_login') diff --git a/roles/blockerbugs/tasks/main.yml b/roles/blockerbugs/tasks/main.yml index ffe176d284..df3a079a19 100644 --- a/roles/blockerbugs/tasks/main.yml +++ b/roles/blockerbugs/tasks/main.yml @@ -51,3 +51,8 @@ tags: - config - blockerbugs + +# hotfix openidbaseclient.py in python-fedora so it doesn't blow up during sync. +# fix is in upstream, waiting for new release: https://github.com/fedora-infra/python-fedora/commit/a5a66e8e744ad1c54788e00d78bd6b66e67fe83b +- name: hotfix python-fedora so it doesn't blow up + copy: src=python-fedora-openidbaseclient-hotfix.py dest=/usr/lib/python2.7/site-packages/fedora/client/openidbaseclient.py owner=root group=root mode=0644