Hello,

tl;dr; I want to add forms based auth in ipalib's rpc client and use method 2.e) for API configuration. Will be used in otp tests (ipa-4-1 upstream). Want to know if you are OK with it. PoC implementation attached.

== Background ==
I want to call FreeIPA API from our Web UI CI tests.

Uses case are:
* speed up data preparation (the biggest time consumer in Web UI tests)
* prepare data or do stuff which could not be done in Web UI (little bit part of https://fedorahosted.org/freeipa/ticket/4772)

For this task I want to reuse ipalib's rpcclient to avoid yet another RPC client implementation.

Plan is to use it in OTP web ui tests.

== ipalib limitations  ==
Please note that web ui tests could be run as a normal user on a machine which is not a FreeIPA client, i.e. on developers machine with local browser to observe the progress.

=== 1 Forms based authentication ===
ipalib doesn't support forms-based auth and kerberos might not be available. I have investigated the option to add forms auth and managed to write a PoC patch(attached) which enables it. Question is whether we want to allow it in ipalib even for non-test uses cases. I think it could be beneficial for some users.

=== 2 API/environment finalization in ipalib/__init__.py for tests ===
Tests which are run by make-test are initialized with
os.environ['IPA_UNIT_TEST_MODE'] = 'cli_test'

in such case, ipalib automatically finalizes API. Unfortunately the default configuration is unusable if run on a machine without IPA configuration files (~/.ipa/default.conf, /etc/ipa/default.conf, ...). We can't even supply the values afterwards because api.env is 'set-once' object.

There are several approaches which could be taken:
Proper fixes:
a) Finish the idea, mentioned in Registry, of storing plugin registrations to allow multiple API creation with all plugins and therefore custom instances of API. Would be probably quite a lot of work because of wide use of global api object. I.e., various test can use their own instance of api.

b) Remove the API initialization in ipalib/__init__.py. Initialize API only in tests which use it. Without a) It might suffer from the same issue as d)

c) Remove 'set-once' constraint from Env object to allow config change after API initialization

Workarounds:
d) Change the os.env variable to a different than 'cli_test' value before api import in uidriver.py to prevent the automatic initialization. Init api in Web UI tests. Might cause issues if other tests were run and initialized api before Web ui tests.

e) override values in Env object by a hack:
  object.__setattr__(api.env, key, val)
  api.env._Env__d[key] = val
Advantage is that it's self-contained in Web UI tests. Must be changed back at the end of test not to interfere with other tests.

f) require IPA configuration file even on non-ipa client machines. Easy, but not very pleasant for user - all required configuration values are already present in webui test config file or in env variables -> multiple files has to be changed when switching between testing instances(frequent use case).

g) other

From longer perspective I would like to see a) and b), maybe c). But I would like to use this feature in otptests which should land in 4.1 -> a) is not an option because of its complexity. Therefore, atm, I would like to go with e) or c)

Thanks
--
Petr Vobornik
From 3c1a140b80b87693a6deb76dc7ebeb4ef479c560 Mon Sep 17 00:00:00 2001
From: Petr Vobornik <[email protected]>
Date: Fri, 12 Dec 2014 16:18:34 +0100
Subject: [PATCH] webui-tests: support direct IPA API calls

---
 ipatests/test_webui/ui_driver.py | 122 ++++++++++++++++++++++++++++++++++++++-
 1 file changed, 121 insertions(+), 1 deletion(-)

diff --git a/ipatests/test_webui/ui_driver.py b/ipatests/test_webui/ui_driver.py
index bf78fcb5798f5f17f2d76650da0c0327bb4c6d71..bde337c1ee2ac69833263eca988945c27d2d3c4a 100644
--- a/ipatests/test_webui/ui_driver.py
+++ b/ipatests/test_webui/ui_driver.py
@@ -52,8 +52,11 @@ try:
     NO_YAML = False
 except ImportError:
     NO_YAML = True
-from urllib2 import URLError
+from urllib2 import URLError, urlparse
 from ipaplatform.paths import paths
+from ipapython import certdb, ipautil
+from ipapython.ipautil import run
+from ipalib import x509, api
 
 ENV_MAP = {
     'MASTER': 'ipa_server',
@@ -117,10 +120,30 @@ class UI_driver(object):
         if NO_SELENIUM:
             raise nose.SkipTest('Selenium not installed')
 
+    @property
+    def api(self):
+        """
+        ipalib API
+        """
+        if not self._api:
+            self.init_api()
+        return self._api
+
+    @property
+    def nss_db(self):
+        """
+        NSS DB for IPA lib rpc client
+        """
+        if not self._nss_db:
+            self._nss_db = self._prepare_nss_dir(self.config['ipa_server'])
+        return self._nss_db
+
     def __init__(self, driver=None, config=None):
         self.request_timeout = 30
         self.driver = driver
         self.config = config
+        self._api = None;
+        self._nss_db = None;
         if not config:
             self.load_config()
 
@@ -174,6 +197,103 @@ class UI_driver(object):
         Test clean up
         """
         self.driver.quit()
+        if self._nss_db:
+            self._nss_db.close()
+        self._restore_env()
+
+    def _prepare_nss_dir(self, ipa_server):
+        """
+        Create temporary NSS Database with IPA server CA cert. CA cert is
+        downloaded over HTTP from IPA server.
+        """
+
+        # create new NSSDatabase
+        tmp_db = certdb.NSSDatabase()
+        pwd_file = ipautil.write_tmp_file(ipautil.ipa_generate_password())
+        tmp_db.create_db(pwd_file.name)
+
+        # download and add cert
+        url = urlparse.urlunparse(('http', ipautil.format_netloc(ipa_server),
+                                   '/ipa/config/ca.crt', '', '', ''))
+        stdout, stderr, rc = run([paths.BIN_WGET, "-O", "-", url])
+        certs = x509.load_certificate_list(stdout, tmp_db.secdir)
+        ca_certs = [cert.der_data for cert in certs]
+        for i, cert in enumerate(ca_certs):
+            tmp_db.add_cert(cert, 'CA certificate %d' % (i + 1), 'C,,')
+
+        return tmp_db
+
+    def init_api(self):
+        """
+        Initialize ipalib API so we can use API commands in UI tests directly.
+        Uses forms-based authentication. It allows Web UI tests to be run a
+        system which is not an IPA client.
+        """
+
+        self._api = api
+        self._env_backup = {}
+
+        def set_env(key, val):
+            self._env_backup[key] = api.env[key]
+            object.__setattr__(api.env, key, val)
+            api.env._Env__d[key] = val
+
+        ipa_server = self.config['ipa_server']
+        set_env('xmlrpc_uri', "https://"; + ipa_server + "/ipa/xml")
+        set_env('jsonrpc_uri', "https://"; + ipa_server + "/ipa/json")
+        set_env('realm', self.config["ipa_realm"])
+        set_env('domain', self.config["ipa_domain"])
+        set_env('basedn', ipautil.realm_to_suffix(self.config["ipa_realm"]))
+
+        #api.bootstrap(
+        #     context='webui_tests',
+        #     in_server=False,
+        #     debug=False,
+        #     verbose=2,
+        #     xmlrpc_uri="https://"; + ipa_server + "/ipa/json",
+        #     # realm, domain and basedn must be set because ipalib produces
+        #     # defaults for options for API calls. Default domain and realm:
+        #     # EXAMPLE.COM would be used otherwise. It would lead to command
+        #     # failures, such as: "RealmMismatch: The realm for the principal
+        #     # does not match the realm for this IPA server"
+        #     realm=self.config["ipa_realm"],
+        #     domain=self.config["ipa_domain"],
+        #     basedn=ipautil.realm_to_suffix(self.config["ipa_realm"]),
+        # )
+        #api.finalize()
+        self.reconnect_api(self.config['ipa_admin'], self.config['ipa_password'])
+
+    def _restore_env(self):
+        """
+        Revert changes in API env
+        """
+        if not self._env_backup or self._api:
+            return
+
+        env = self.api.env
+        def restore(key, val):
+            object.__setattr__(env, key, val)
+            env._Env__d[key] = val
+
+        for k,v in self._env_backup:
+            restore(k, v)
+
+        self._env_backup = None
+
+    def reconnect_api(self, user, password):
+        """
+        Reconnect rpcclient as different user with password
+        """
+        self.disconnect_api()
+        self.api.Backend.rpcclient.connect(
+            nss_dir=self.nss_db.secdir,
+            user=user,
+            password=password
+        )
+
+    def disconnect_api(self):
+        if self._api and self.api.Backend.rpcclient.isconnected():
+            self.api.Backend.rpcclient.disconnect()
 
     def get_driver(self):
         """
-- 
1.9.3

From 29244f8dce7ba7242848ec79d339e2630e518849 Mon Sep 17 00:00:00 2001
From: Petr Vobornik <[email protected]>
Date: Wed, 10 Dec 2014 18:57:51 +0100
Subject: [PATCH] rpc-client: add forms based auth support

---
 ipalib/rpc.py | 311 ++++++++++++++++++++++++++++++++++++++--------------------
 1 file changed, 202 insertions(+), 109 deletions(-)

diff --git a/ipalib/rpc.py b/ipalib/rpc.py
index 806f6bb9adf004660c9cb285cf31b09a988afa93..8f9836bca84db969f2e5456ab85bf1d9fda559cc 100644
--- a/ipalib/rpc.py
+++ b/ipalib/rpc.py
@@ -38,9 +38,11 @@ import os
 import locale
 import base64
 import urllib
+import urllib2
 import json
 import socket
-from urllib2 import urlparse
+from urllib2 import urlparse, HTTPError
+import cookielib
 
 from xmlrpclib import (Binary, Fault, DateTime, dumps, loads, ServerProxy,
         Transport, ProtocolError, MININT, MAXINT)
@@ -52,7 +54,8 @@ from nss.error import NSPRError
 from ipalib.backend import Connectible
 from ipalib.constants import LDAP_GENERALIZED_TIME_FORMAT
 from ipalib.errors import (public_errors, UnknownError, NetworkError,
-    KerberosError, XMLRPCMarshallError, JSONError, ConversionError)
+    KerberosError, XMLRPCMarshallError, JSONError, ConversionError,
+    InvalidSessionPassword)
 from ipalib import errors, capabilities
 from ipalib.request import context, Connection
 from ipalib.util import get_current_principal
@@ -502,7 +505,75 @@ class SSLTransport(LanguageAwareTransport):
             return self._connection[1]
 
 
-class KerbTransport(SSLTransport):
+class SessionTransport(SSLTransport):
+
+    def get_host_info(self, host):
+        """
+        Adds session cookie
+        """
+        (host, extra_headers, x509) = LanguageAwareTransport.get_host_info(self, host)
+
+        if not isinstance(extra_headers, list):
+            extra_headers = []
+
+        session_cookie = getattr(context, 'session_cookie', None)
+        root_logger.debug("SessionTransport, using session: " + str(session_cookie))
+        if session_cookie:
+            extra_headers.append(('Cookie', session_cookie))
+        return (host, extra_headers, x509)
+
+    def store_session_cookie(self, cookie_header):
+        '''
+        Given the contents of a Set-Cookie header scan the header and
+        extract each cookie contained within until the session cookie
+        is located. Examine the session cookie if the domain and path
+        are specified, if not update the cookie with those values from
+        the request URL. Then write the session cookie into the key
+        store for the principal. If the cookie header is None or the
+        session cookie is not present in the header no action is
+        taken.
+
+        Context Dependencies:
+
+        The per thread context is expected to contain:
+            principal
+                The current pricipal the HTTP request was issued for.
+            request_url
+                The URL of the HTTP request.
+
+        '''
+
+        if cookie_header is None:
+            return
+
+        principal = getattr(context, 'principal', None)
+        request_url = getattr(context, 'request_url', None)
+        root_logger.debug("received Set-Cookie '%s'", cookie_header)
+
+        # Search for the session cookie
+        try:
+            session_cookie = Cookie.get_named_cookie_from_string(cookie_header,
+                                                                 COOKIE_NAME, request_url)
+        except Exception, e:
+            root_logger.error("unable to parse cookie header '%s': %s", cookie_header, e)
+            return
+
+        if session_cookie is None:
+            return
+
+        cookie_string = str(session_cookie)
+        root_logger.debug("storing cookie '%s' for principal %s", cookie_string, principal)
+        try:
+            update_persistent_client_session_data(principal, cookie_string)
+        except Exception, e:
+            # Not fatal, we just can't use the session cookie we were sent.
+            pass
+
+    def parse_response(self, response):
+        self.store_session_cookie(response.getheader('Set-Cookie'))
+        return SSLTransport.parse_response(self, response)
+
+class KerbTransport(SessionTransport):
     """
     Handles Kerberos Negotiation authentication to an XML-RPC server.
     """
@@ -530,14 +601,10 @@ class KerbTransport(SSLTransport):
         Two things can happen here. If we have a session we will add
         a cookie for that. If not we will set an Authorization header.
         """
-        (host, extra_headers, x509) = SSLTransport.get_host_info(self, host)
-
-        if not isinstance(extra_headers, list):
-            extra_headers = []
+        (host, extra_headers, x509) = SessionTransport.get_host_info(self, host)
 
         session_cookie = getattr(context, 'session_cookie', None)
         if session_cookie:
-            extra_headers.append(('Cookie', session_cookie))
             return (host, extra_headers, x509)
 
         # Set the remote host principal
@@ -566,61 +633,10 @@ class KerbTransport(SSLTransport):
 
     def single_request(self, host, handler, request_body, verbose=0):
         try:
-            return SSLTransport.single_request(self, host, handler, request_body, verbose)
+            return SessionTransport.single_request(self, host, handler, request_body, verbose)
         finally:
             self.close()
 
-    def store_session_cookie(self, cookie_header):
-        '''
-        Given the contents of a Set-Cookie header scan the header and
-        extract each cookie contained within until the session cookie
-        is located. Examine the session cookie if the domain and path
-        are specified, if not update the cookie with those values from
-        the request URL. Then write the session cookie into the key
-        store for the principal. If the cookie header is None or the
-        session cookie is not present in the header no action is
-        taken.
-
-        Context Dependencies:
-
-        The per thread context is expected to contain:
-            principal
-                The current pricipal the HTTP request was issued for.
-            request_url
-                The URL of the HTTP request.
-
-        '''
-
-        if cookie_header is None:
-            return
-
-        principal = getattr(context, 'principal', None)
-        request_url = getattr(context, 'request_url', None)
-        root_logger.debug("received Set-Cookie '%s'", cookie_header)
-
-        # Search for the session cookie
-        try:
-            session_cookie = Cookie.get_named_cookie_from_string(cookie_header,
-                                                                 COOKIE_NAME, request_url)
-        except Exception, e:
-            root_logger.error("unable to parse cookie header '%s': %s", cookie_header, e)
-            return
-
-        if session_cookie is None:
-            return
-
-        cookie_string = str(session_cookie)
-        root_logger.debug("storing cookie '%s' for principal %s", cookie_string, principal)
-        try:
-            update_persistent_client_session_data(principal, cookie_string)
-        except Exception, e:
-            # Not fatal, we just can't use the session cookie we were sent.
-            pass
-
-    def parse_response(self, response):
-        self.store_session_cookie(response.getheader('Set-Cookie'))
-        return SSLTransport.parse_response(self, response)
-
 
 class DelegatedKerbTransport(KerbTransport):
     """
@@ -758,24 +774,47 @@ class RPCClient(Connectible):
         setattr(context, 'session_cookie', session_cookie.http_cookie())
 
         # Form the session URL by substituting the session path into the original URL
-        scheme, netloc, path, params, query, fragment = urlparse.urlparse(original_url)
+        return self.get_session_url(original_url)
+
+    def get_session_url(self, url):
+        scheme, netloc, path, params, query, fragment = urlparse.urlparse(url)
         path = self.session_path
         session_url = urlparse.urlunparse((scheme, netloc, path, params, query, fragment))
-
         return session_url
 
+
+    def destroy_session(self, principal):
+        if hasattr(context, 'session_cookie'):
+            delattr(context, 'session_cookie')
+            try:
+                delete_persistent_client_session_data(principal)
+            except Exception, e:
+                # This shouldn't happen if we have a session but it isn't fatal.
+                pass
+
     def create_connection(self, ccache=None, verbose=0, fallback=True,
-                          delegate=False, nss_dir=None):
-        try:
-            rpc_uri = self.env[self.env_rpc_uri_key]
+                          delegate=False, nss_dir=None, user=None, password=None):
+        """
+        Create connection
+        """
+
+        setattr(context, 'user', user)
+        setattr(context, 'password', password)
+        use_krb = not user or not password
+
+        rpc_uri = self.env[self.env_rpc_uri_key]
+        if use_krb:
             principal = get_current_principal()
-            setattr(context, 'principal', principal)
+        else:
+            principal = user
+        setattr(context, 'principal', principal)
+        try:
             # We have a session cookie, try using the session URI to see if it
             # is still valid
             if not delegate:
                 rpc_uri = self.apply_session_cookie(rpc_uri)
         except ValueError:
-            # No session key, do full Kerberos auth
+            # No session key, do full auth
             pass
         # This might be dangerous. Use at your own risk!
         if nss_dir:
@@ -786,7 +825,9 @@ class RPCClient(Connectible):
             kw = dict(allow_none=True, encoding='UTF-8')
             kw['verbose'] = verbose
             if url.startswith('https://'):
-                if delegate:
+                if not use_krb:
+                    transport_class = SessionTransport
+                elif delegate:
                     transport_class = DelegatedKerbTransport
                 else:
                     transport_class = KerbTransport
@@ -794,46 +835,39 @@ class RPCClient(Connectible):
                 transport_class = LanguageAwareTransport
             kw['transport'] = transport_class(protocol=self.protocol)
             self.log.info('trying %s' % url)
-            setattr(context, 'request_url', url)
-            serverproxy = self.server_proxy_class(url, **kw)
-            if len(urls) == 1:
-                # if we have only 1 server and then let the
-                # main requester handle any errors. This also means it
-                # must handle a 401 but we save a ping.
-                return serverproxy
             try:
-                command = getattr(serverproxy, 'ping')
-                try:
-                    response = command([], {})
-                except Fault, e:
-                    e = decode_fault(e)
-                    if e.faultCode in errors_by_code:
-                        error = errors_by_code[e.faultCode]
-                        raise error(message=e.faultString)
-                    else:
-                        raise UnknownError(
-                            code=e.faultCode,
-                            error=e.faultString,
-                            server=url,
-                        )
-                # We don't care about the response, just that we got one
+                if use_krb:
+                    setattr(context, 'request_url', url)
+                    serverproxy = self.server_proxy_class(url, **kw)
+                    if len(urls) == 1:
+                        # if we have only 1 server and then let the
+                        # main requester handle any errors. This also means it
+                        # must handle a 401 but we save a ping.
+                        return serverproxy
+                    self.krb_auth(serverproxy, url)
+                else:
+                    self.forms_auth(url, principal, user, password)
+                    # further forms-based communication requires session url
+                    if self.session_path not in url:
+                        url = self.get_session_url(url)
+                    setattr(context, 'request_url', url)
+                    serverproxy = self.server_proxy_class(url, **kw)
                 break
-            except KerberosError, krberr:
-                # kerberos error on one server is likely on all
-                raise errors.KerberosError(major=str(krberr), minor='')
+
             except ProtocolError, e:
                 if hasattr(context, 'session_cookie') and e.errcode == 401:
-                    # Unauthorized. Remove the session and try again.
-                    delattr(context, 'session_cookie')
-                    try:
-                        delete_persistent_client_session_data(principal)
-                    except Exception, e:
-                        # This shouldn't happen if we have a session but it isn't fatal.
-                        pass
-                    return self.create_connection(ccache, verbose, fallback, delegate)
+                    self.destroy_session(principal)
+                    return self.create_connection(ccache, verbose, fallback, delegate, nss_dir, user, password)
                 if not fallback:
                     raise
                 serverproxy = None
+            except HTTPError, e:
+                if e.code == 401:
+                    self.destroy_session(principal)
+                if not fallback:
+                    raise
+                self.log.info('Connection to %s failed with %s', url, e)
+                serverproxy = None
             except Exception, e:
                 if not fallback:
                     raise
@@ -846,6 +880,63 @@ class RPCClient(Connectible):
                 error=', '.join(urls))
         return serverproxy
 
+    def krb_auth(self, serverproxy, url):
+        try:
+            command = getattr(serverproxy, 'ping')
+            try:
+                response = command([], {})
+            except Fault, e:
+                e = decode_fault(e)
+                if e.faultCode in errors_by_code:
+                    error = errors_by_code[e.faultCode]
+                    raise error(message=e.faultString)
+                else:
+                    raise UnknownError(
+                        code=e.faultCode,
+                        error=e.faultString,
+                        server=url,
+                    )
+        except KerberosError, krberr:
+            # kerberos error on one server is likely on all
+            raise errors.KerberosError(major=str(krberr), minor='')
+
+    def forms_auth(self, rpc_url, principal, user, password):
+        """
+        Try forms-based authentication.
+        """
+        (scheme, netloc, path, params, query, fragment
+            ) = urlparse.urlparse(rpc_url)
+        login_url = urlparse.urlunparse((scheme, netloc, 'ipa/session/login_password', '', '', ''))
+        self.log.debug('Forms-based authentication for: %s' % rpc_url)
+        self.log.debug('User: %s' % user)
+
+        headers = {
+            'Content-type': 'application/x-www-form-urlencoded; charset=UTF-8',
+            'Referer': 'https://%s/ipa/xml' % str(netloc),
+        }
+        data = {
+            'user': user,
+            'password': password,
+        }
+        data = urllib.urlencode(data)
+        handlers = [urllib2.HTTPSHandler()]
+        opener = urllib2.build_opener(*handlers)
+        req = urllib2.Request(login_url, data, headers)
+        response = urllib2.urlopen(req)
+        if response.getcode() == 200:
+            self.log.debug("Forms based auth successfull")
+            session_cookie = Cookie.get_named_cookie_from_string(
+                response.info().getheader('Set-Cookie'),
+                COOKIE_NAME,
+                rpc_url
+            )
+            cookie_string = str(session_cookie)
+            update_persistent_client_session_data(principal, cookie_string)
+            ipa_cookie = session_cookie.http_cookie()
+            setattr(context, 'session_cookie', ipa_cookie)
+            self.log.debug("forms_auth session:" + ipa_cookie)
+
+
     def destroy_connection(self):
         if sys.version_info >= (2, 7):
             conn = getattr(context, self.id, None)
@@ -902,19 +993,21 @@ class RPCClient(Connectible):
             session_cookie = getattr(context, 'session_cookie', None)
             if session_cookie and e.errcode == 401:
                 # Unauthorized. Remove the session and try again.
-                delattr(context, 'session_cookie')
-                try:
-                    principal = getattr(context, 'principal', None)
-                    delete_persistent_client_session_data(principal)
-                except Exception, e:
-                    # This shouldn't happen if we have a session but it isn't fatal.
-                    pass
+                self.destroy_session(getattr(context, 'principal', None))
 
                 # Create a new serverproxy with the non-session URI. If there
                 # is an existing connection we need to save the NSS dbdir so
                 # we can skip an unnecessary NSS_Initialize() and avoid
                 # NSS_Shutdown issues.
-                serverproxy = self.create_connection(os.environ.get('KRB5CCNAME'), self.env.verbose, self.env.fallback, self.env.delegate)
+                serverproxy = self.create_connection(
+                    os.environ.get('KRB5CCNAME'),
+                    self.env.verbose,
+                    self.env.fallback,
+                    self.env.delegate,
+                    context.nss_dir,
+                    getattr(context, 'user'),
+                    getattr(context, 'password')
+                )
 
                 dbdir = None
                 current_conn = getattr(context, self.id, None)
-- 
1.9.3

_______________________________________________
Freeipa-devel mailing list
[email protected]
https://www.redhat.com/mailman/listinfo/freeipa-devel

Reply via email to