# Authors: # Jason Gerard DeRose # # Copyright (C) 2008-2016 Red Hat # see file 'COPYING' for use and warranty information # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """ RPC server. Also see the `ipalib.rpc` module. """ from xml.sax.saxutils import escape import os import traceback import gssapi import requests import ldap.controls from pyasn1.type import univ, namedtype from pyasn1.codec.ber import encoder import six # pylint: disable=import-error from six.moves.urllib.parse import parse_qs from six.moves.xmlrpc_client import Fault # pylint: enable=import-error from ipalib import plugable, errors from ipalib.capabilities import VERSION_WITHOUT_CAPABILITIES from ipalib.frontend import Local from ipalib.install.kinit import kinit_armor, kinit_password from ipalib.backend import Executioner from ipalib.errors import (PublicError, InternalError, JSONError, CCacheError, RefererError, InvalidSessionPassword, NotFound, ACIError, ExecutionError, PasswordExpired, KrbPrincipalExpired, UserLocked) from ipalib.request import context, destroy_context from ipalib.rpc import (xml_dumps, xml_loads, json_encode_binary, json_decode_binary) from ipalib.util import normalize_name from ipapython.dn import DN from ipaserver.plugins.ldap2 import ldap2 from ipalib.backend import Backend from ipalib.krb_utils import ( krb5_format_principal_name, get_credentials_if_valid) from ipapython import ipautil from ipaplatform.paths import paths from ipapython.version import VERSION from ipalib.text import _ from base64 import b64decode, b64encode from requests.auth import AuthBase if six.PY3: unicode = str HTTP_STATUS_SUCCESS = '200 Success' HTTP_STATUS_SERVER_ERROR = '500 Internal Server Error' _not_found_template = """ 404 Not Found

Not Found

The requested URL %(url)s was not found on this server.

""" _bad_request_template = """ 400 Bad Request

Bad Request

%(message)s

""" _internal_error_template = """ 500 Internal Server Error

Internal Server Error

%(message)s

""" _unauthorized_template = """ 401 Unauthorized

Invalid Authentication

%(message)s

""" _success_template = """ 200 Success

%(title)s

%(message)s

""" class HTTP_Status(plugable.Plugin): def not_found(self, environ, start_response, url, message): """ Return a 404 Not Found error. """ status = '404 Not Found' response_headers = [('Content-Type', 'text/html; charset=utf-8')] self.info('%s: URL="%s", %s', status, url, message) start_response(status, response_headers) output = _not_found_template % dict(url=escape(url)) return [output.encode('utf-8')] def bad_request(self, environ, start_response, message): """ Return a 400 Bad Request error. """ status = '400 Bad Request' response_headers = [('Content-Type', 'text/html; charset=utf-8')] self.info('%s: %s', status, message) start_response(status, response_headers) output = _bad_request_template % dict(message=escape(message)) return [output.encode('utf-8')] def internal_error(self, environ, start_response, message): """ Return a 500 Internal Server Error. """ status = HTTP_STATUS_SERVER_ERROR response_headers = [('Content-Type', 'text/html; charset=utf-8')] self.error('%s: %s', status, message) start_response(status, response_headers) output = _internal_error_template % dict(message=escape(message)) return [output.encode('utf-8')] def unauthorized(self, environ, start_response, message, reason): """ Return a 401 Unauthorized error. """ status = '401 Unauthorized' response_headers = [('Content-Type', 'text/html; charset=utf-8')] if reason: response_headers.append(('X-IPA-Rejection-Reason', reason)) self.info('%s: %s', status, message) start_response(status, response_headers) output = _unauthorized_template % dict(message=escape(message)) return [output.encode('utf-8')] def read_input(environ): """ Read the request body from environ['wsgi.input']. """ try: length = int(environ.get('CONTENT_LENGTH')) except (ValueError, TypeError): return return environ['wsgi.input'].read(length).decode('utf-8') def params_2_args_options(params): if len(params) == 0: return (tuple(), dict()) if len(params) == 1: return (params[0], dict()) return (params[0], params[1]) def nicify_query(query, encoding='utf-8'): if not query: return for (key, value) in query.items(): if len(value) == 0: yield (key, None) elif len(value) == 1: yield (key, value[0].decode(encoding)) else: yield (key, tuple(v.decode(encoding) for v in value)) def extract_query(environ): """ Return the query as a ``dict``, or ``None`` if no query is presest. """ qstr = None if environ['REQUEST_METHOD'] == 'POST': if environ['CONTENT_TYPE'] == 'application/x-www-form-urlencoded': qstr = read_input(environ) elif environ['REQUEST_METHOD'] == 'GET': qstr = environ['QUERY_STRING'] if qstr: query = dict(nicify_query(parse_qs(qstr))) # keep_blank_values=True) else: query = {} environ['wsgi.query'] = query return query class wsgi_dispatch(Executioner, HTTP_Status): """ WSGI routing middleware and entry point into IPA server. The `wsgi_dispatch` plugin is the entry point into the IPA server. It dispatchs the request to the appropriate wsgi application handler which is specific to the authentication and RPC mechanism. """ def __init__(self, api): super(wsgi_dispatch, self).__init__(api) self.__apps = {} def __iter__(self): for key in sorted(self.__apps): yield key def __getitem__(self, key): return self.__apps[key] def __contains__(self, key): return key in self.__apps def __call__(self, environ, start_response): self.debug('WSGI wsgi_dispatch.__call__:') try: return self.route(environ, start_response) finally: destroy_context() def _on_finalize(self): self.url = self.env['mount_ipa'] super(wsgi_dispatch, self)._on_finalize() def route(self, environ, start_response): key = environ.get('PATH_INFO') if key in self.__apps: app = self.__apps[key] return app(environ, start_response) url = environ['SCRIPT_NAME'] + environ['PATH_INFO'] return self.not_found(environ, start_response, url, 'URL fragment "%s" does not have a handler' % (key)) def mount(self, app, key): """ Mount the WSGI application *app* at *key*. """ # if self.__islocked__(): # raise Exception('%s.mount(): locked, cannot mount %r at %r' % ( # self.name, app, key) # ) if key in self.__apps: raise Exception('%s.mount(): cannot replace %r with %r at %r' % ( self.name, self.__apps[key], app, key) ) self.debug('Mounting %r at %r', app, key) self.__apps[key] = app class WSGIExecutioner(Executioner): """ Base class for execution backends with a WSGI application interface. """ headers = None content_type = None key = '' _system_commands = {} def _on_finalize(self): self.url = self.env.mount_ipa + self.key super(WSGIExecutioner, self)._on_finalize() if 'wsgi_dispatch' in self.api.Backend: self.api.Backend.wsgi_dispatch.mount(self, self.key) def _get_command(self, name): try: # assume version 1 for unversioned command calls command = self.api.Command[name, '1'] except KeyError: try: command = self.api.Command[name] except KeyError: command = None if command is None or isinstance(command, Local): raise errors.CommandError(name=name) return command def wsgi_execute(self, environ): result = None error = None _id = None lang = os.environ['LANG'] name = None args = () options = {} command = None e = None if not 'HTTP_REFERER' in environ: return self.marshal(result, RefererError(referer='missing'), _id) if not environ['HTTP_REFERER'].startswith('https://%s/ipa' % self.api.env.host) and not self.env.in_tree: return self.marshal(result, RefererError(referer=environ['HTTP_REFERER']), _id) try: if ('HTTP_ACCEPT_LANGUAGE' in environ): lang_reg_w_q = environ['HTTP_ACCEPT_LANGUAGE'].split(',')[0] lang_reg = lang_reg_w_q.split(';')[0] lang_ = lang_reg.split('-')[0] if '-' in lang_reg: reg = lang_reg.split('-')[1].upper() else: reg = lang_.upper() os.environ['LANG'] = '%s_%s' % (lang_, reg) if ( environ.get('CONTENT_TYPE', '').startswith(self.content_type) and environ['REQUEST_METHOD'] == 'POST' ): data = read_input(environ) (name, args, options, _id) = self.unmarshal(data) else: (name, args, options, _id) = self.simple_unmarshal(environ) if name in self._system_commands: result = self._system_commands[name](self, *args, **options) else: command = self._get_command(name) result = command(*args, **options) except PublicError as e: if self.api.env.debug: self.debug('WSGI wsgi_execute PublicError: %s', traceback.format_exc()) error = e except Exception as e: self.exception( 'non-public: %s: %s', e.__class__.__name__, str(e) ) error = InternalError() finally: os.environ['LANG'] = lang principal = getattr(context, 'principal', 'UNKNOWN') if command is not None: try: params = command.args_options_2_params(*args, **options) except Exception as e: self.info( 'exception %s caught when converting options: %s', e.__class__.__name__, str(e) ) # get at least some context of what is going on params = options error = e if error: result_string = type(error).__name__ else: result_string = 'SUCCESS' self.info('[%s] %s: %s(%s): %s', type(self).__name__, principal, name, ', '.join(command._repr_iter(**params)), result_string) else: self.info('[%s] %s: %s: %s', type(self).__name__, principal, name, type(error).__name__) version = options.get('version', VERSION_WITHOUT_CAPABILITIES) return self.marshal(result, error, _id, version) def simple_unmarshal(self, environ): name = environ['PATH_INFO'].strip('/') options = extract_query(environ) return (name, tuple(), options, None) def __call__(self, environ, start_response): """ WSGI application for execution. """ self.debug('WSGI WSGIExecutioner.__call__:') try: status = HTTP_STATUS_SUCCESS response = self.wsgi_execute(environ) if self.headers: headers = self.headers else: headers = [('Content-Type', self.content_type + '; charset=utf-8')] except Exception: self.exception('WSGI %s.__call__():', self.name) status = HTTP_STATUS_SERVER_ERROR response = status.encode('utf-8') headers = [('Content-Type', 'text/plain; charset=utf-8')] start_response(status, headers) return [response] def unmarshal(self, data): raise NotImplementedError('%s.unmarshal()' % type(self).__name__) def marshal(self, result, error, _id=None, version=VERSION_WITHOUT_CAPABILITIES): raise NotImplementedError('%s.marshal()' % type(self).__name__) class jsonserver(WSGIExecutioner, HTTP_Status): """ JSON RPC server. For information on the JSON-RPC spec, see: http://json-rpc.org/wiki/specification """ content_type = 'application/json' def __call__(self, environ, start_response): ''' ''' self.debug('WSGI jsonserver.__call__:') response = super(jsonserver, self).__call__(environ, start_response) return response def marshal(self, result, error, _id=None, version=VERSION_WITHOUT_CAPABILITIES): if error: assert isinstance(error, PublicError) error = dict( code=error.errno, message=error.strerror, data=error.kw, name=unicode(error.__class__.__name__), ) principal = getattr(context, 'principal', 'UNKNOWN') response = dict( result=result, error=error, id=_id, principal=unicode(principal), version=unicode(VERSION), ) dump = json_encode_binary(response, version) return dump.encode('utf-8') def unmarshal(self, data): try: d = json_decode_binary(data) except ValueError as e: raise JSONError(error=e) if not isinstance(d, dict): raise JSONError(error=_('Request must be a dict')) if 'method' not in d: raise JSONError(error=_('Request is missing "method"')) if 'params' not in d: raise JSONError(error=_('Request is missing "params"')) method = d['method'] params = d['params'] _id = d.get('id') if not isinstance(params, (list, tuple)): raise JSONError(error=_('params must be a list')) if len(params) != 2: raise JSONError(error=_('params must contain [args, options]')) args = params[0] if not isinstance(args, (list, tuple)): raise JSONError(error=_('params[0] (aka args) must be a list')) options = params[1] if not isinstance(options, dict): raise JSONError(error=_('params[1] (aka options) must be a dict')) options = dict((str(k), v) for (k, v) in options.items()) return (method, args, options, _id) class NegotiateAuth(AuthBase): """Negotiate Augh using python GSSAPI""" def __init__(self, target_host, ccache_name=None): self.context = None self.target_host = target_host self.ccache_name = ccache_name def __call__(self, request): self.initial_step(request) request.register_hook('response', self.handle_response) return request def deregister(self, response): response.request.deregister_hook('response', self.handle_response) def _get_negotiate_token(self, response): token = None if response is not None: h = response.headers.get('www-authenticate', '') if h.startswith('Negotiate'): val = h[h.find('Negotiate') + len('Negotiate'):].strip() if len(val) > 0: token = b64decode(val) return token def _set_authz_header(self, request, token): request.headers['Authorization'] = 'Negotiate ' + b64encode(token) def initial_step(self, request, response=None): if self.context is None: store = {'ccache': self.ccache_name} creds = gssapi.Credentials(usage='initiate', store=store) name = gssapi.Name('HTTP@{0}'.format(self.target_host), name_type=gssapi.NameType.hostbased_service) self.context = gssapi.SecurityContext(creds=creds, name=name, usage='initiate') in_token = self._get_negotiate_token(response) out_token = self.context.step(in_token) self._set_authz_header(request, out_token) def handle_response(self, response, **kwargs): status = response.status_code if status >= 400 and status != 401: return response in_token = self._get_negotiate_token(response) if in_token is not None: out_token = self.context.step(in_token) if self.context.complete: return response elif not out_token: return response self._set_authz_header(response.request, out_token) # use response so we can make another request _ = response.content # pylint: disable=unused-variable response.raw.release_conn() newresp = response.connection.send(response.request, **kwargs) newresp.history.append(response) return self.handle_response(newresp, **kwargs) return response class KerberosSession(HTTP_Status): ''' Functionally shared by all RPC handlers using both sessions and Kerberos. This class must be implemented as a mixin class rather than the more obvious technique of subclassing because the classes needing this do not share a common base class. ''' def finalize_kerberos_acquisition(self, who, ccache_name, environ, start_response, headers=None): if headers is None: headers = [] # Connect back to ourselves to get mod_auth_gssapi to # generate a cookie for us. try: target = self.api.env.host r = requests.get('http://{0}/ipa/session/cookie'.format(target), auth=NegotiateAuth(target, ccache_name)) session_cookie = r.cookies.get("ipa_session") if not session_cookie: raise ValueError('No session cookie found') except Exception as e: return self.unauthorized(environ, start_response, str(e), 'Authentication failed') headers.append(('IPASESSION', session_cookie)) start_response(HTTP_STATUS_SUCCESS, headers) return [''] class KerberosWSGIExecutioner(WSGIExecutioner, KerberosSession): """Base class for xmlserver and jsonserver_kerb """ def _on_finalize(self): super(KerberosWSGIExecutioner, self)._on_finalize() def __call__(self, environ, start_response): self.debug('KerberosWSGIExecutioner.__call__:') user_ccache=environ.get('KRB5CCNAME') self.headers = [('Content-Type', '%s; charset=utf-8' % self.content_type)] if user_ccache is None: status = HTTP_STATUS_SERVER_ERROR self.log.error( '%s: %s', status, 'KerberosWSGIExecutioner.__call__: ' 'KRB5CCNAME not defined in HTTP request environment') return self.marshal(None, CCacheError()) logout_cookie = getattr(context, 'logout_cookie', None) if logout_cookie: self.headers.append(('IPASESSION', logout_cookie)) try: self.create_context(ccache=user_ccache) response = super(KerberosWSGIExecutioner, self).__call__( environ, start_response) except PublicError as e: status = HTTP_STATUS_SUCCESS response = status.encode('utf-8') start_response(status, self.headers) return self.marshal(None, e) finally: destroy_context() return response class xmlserver(KerberosWSGIExecutioner): """ Execution backend plugin for XML-RPC server. Also see the `ipalib.rpc.xmlclient` plugin. """ content_type = 'text/xml' key = '/xml' def listMethods(self, *params): """list methods for XML-RPC introspection""" if params: raise errors.ZeroArgumentError(name='system.listMethods') return (tuple(unicode(cmd.name) for cmd in self.Command() if cmd is self.Command[cmd.name]) + tuple(unicode(name) for name in self._system_commands)) def _get_method_name(self, name, *params): """Get a method name for XML-RPC introspection commands""" if not params: raise errors.RequirementError(name='method name') elif len(params) > 1: raise errors.MaxArgumentError(name=name, count=1) [method_name] = params return method_name def methodSignature(self, *params): """get method signature for XML-RPC introspection""" method_name = self._get_method_name('system.methodSignature', *params) if method_name in self._system_commands: # TODO # for now let's not go out of our way to document standard XML-RPC return u'undef' else: self._get_command(method_name) # All IPA commands return a dict (struct), # and take a params, options - list and dict (array, struct) return [[u'struct', u'array', u'struct']] def methodHelp(self, *params): """get method docstring for XML-RPC introspection""" method_name = self._get_method_name('system.methodHelp', *params) if method_name in self._system_commands: return u'' else: command = self._get_command(method_name) return unicode(command.doc or '') _system_commands = { 'system.listMethods': listMethods, 'system.methodSignature': methodSignature, 'system.methodHelp': methodHelp, } def unmarshal(self, data): (params, name) = xml_loads(data) if name in self._system_commands: # For XML-RPC introspection, return params directly return (name, params, {}, None) (args, options) = params_2_args_options(params) if 'version' not in options: # Keep backwards compatibility with client containing # bug https://fedorahosted.org/freeipa/ticket/3294: # If `version` is not given in XML-RPC, assume an old version options['version'] = VERSION_WITHOUT_CAPABILITIES return (name, args, options, None) def marshal(self, result, error, _id=None, version=VERSION_WITHOUT_CAPABILITIES): if error: self.debug('response: %s: %s', error.__class__.__name__, str(error)) response = Fault(error.errno, error.strerror) else: if isinstance(result, dict): self.debug('response: entries returned %d', result.get('count', 1)) response = (result,) dump = xml_dumps(response, version, methodresponse=True) return dump.encode('utf-8') class jsonserver_session(jsonserver, KerberosSession): """ JSON RPC server protected with session auth. """ key = '/session/json' def __init__(self, api): super(jsonserver_session, self).__init__(api) def _on_finalize(self): super(jsonserver_session, self)._on_finalize() def need_login(self, start_response): status = '401 Unauthorized' headers = [] response = b'' self.debug('jsonserver_session: %s need login', status) start_response(status, headers) return [response] def __call__(self, environ, start_response): ''' ''' self.debug('WSGI jsonserver_session.__call__:') ccache_name = environ.get('KRB5CCNAME') # Redirect to login if no Kerberos credentials if ccache_name is None: self.debug('no ccache, need login') return self.need_login(start_response) # Redirect to login if Kerberos credentials are expired creds = get_credentials_if_valid(ccache_name=ccache_name) if not creds: self.debug('ccache expired, deleting session, need login') # The request is finished with the ccache, destroy it. return self.need_login(start_response) # Store the ccache name in the per-thread context setattr(context, 'ccache_name', ccache_name) # This may fail if a ticket from wrong realm was handled via browser try: self.create_context(ccache=ccache_name) except ACIError as e: return self.unauthorized(environ, start_response, str(e), 'denied') try: response = super(jsonserver_session, self).__call__(environ, start_response) finally: destroy_context() return response class jsonserver_kerb(jsonserver, KerberosWSGIExecutioner): """ JSON RPC server protected with kerberos auth. """ key = '/json' class KerberosLogin(Backend, KerberosSession): key = None def _on_finalize(self): super(KerberosLogin, self)._on_finalize() self.api.Backend.wsgi_dispatch.mount(self, self.key) def __call__(self, environ, start_response): self.debug('WSGI KerberosLogin.__call__:') # Get the ccache created by mod_auth_gssapi user_ccache_name=environ.get('KRB5CCNAME') if user_ccache_name is None: return self.internal_error(environ, start_response, 'login_kerberos: KRB5CCNAME not defined in HTTP request environment') return self.finalize_kerberos_acquisition('login_kerberos', user_ccache_name, environ, start_response) class login_kerberos(KerberosLogin): key = '/session/login_kerberos' class login_x509(KerberosLogin): key = '/session/login_x509' class login_password(Backend, KerberosSession): content_type = 'text/plain' key = '/session/login_password' def _on_finalize(self): super(login_password, self)._on_finalize() self.api.Backend.wsgi_dispatch.mount(self, self.key) def __call__(self, environ, start_response): self.debug('WSGI login_password.__call__:') # Get the user and password parameters from the request content_type = environ.get('CONTENT_TYPE', '').lower() if not content_type.startswith('application/x-www-form-urlencoded'): return self.bad_request(environ, start_response, "Content-Type must be application/x-www-form-urlencoded") method = environ.get('REQUEST_METHOD', '').upper() if method == 'POST': query_string = read_input(environ) else: return self.bad_request(environ, start_response, "HTTP request method must be POST") try: query_dict = parse_qs(query_string) except Exception as e: return self.bad_request(environ, start_response, "cannot parse query data") user = query_dict.get('user', None) if user is not None: if len(user) == 1: user = user[0] else: return self.bad_request(environ, start_response, "more than one user parameter") else: return self.bad_request(environ, start_response, "no user specified") # allows login in the form user@SERVER_REALM or user@server_realm # FIXME: uppercasing may be removed when better handling of UPN # is introduced parts = normalize_name(user) if "domain" in parts: # username is of the form user@SERVER_REALM or user@server_realm # check whether the realm is server's realm # Users from other realms are not supported # (they do not have necessary LDAP entry, LDAP connect will fail) if parts["domain"].upper()==self.api.env.realm: user=parts["name"] else: return self.unauthorized(environ, start_response, '', 'denied') elif "flatname" in parts: # username is of the form NetBIOS\user return self.unauthorized(environ, start_response, '', 'denied') else: # username is of the form user or of some wild form, e.g. # user@REALM1@REALM2 or NetBIOS1\NetBIOS2\user (see normalize_name) # wild form username will fail at kinit, so nothing needs to be done pass password = query_dict.get('password', None) if password is not None: if len(password) == 1: password = password[0] else: return self.bad_request(environ, start_response, "more than one password parameter") else: return self.bad_request(environ, start_response, "no password specified") # Get the ccache we'll use and attempt to get credentials in it with user,password ipa_ccache_name = os.path.join(paths.IPA_CCACHES, 'kinit_{}'.format(os.getpid())) try: # try to remove in case an old file was there os.unlink(ipa_ccache_name) except OSError: pass try: self.kinit(user, self.api.env.realm, password, ipa_ccache_name) except PasswordExpired as e: return self.unauthorized(environ, start_response, str(e), 'password-expired') except InvalidSessionPassword as e: return self.unauthorized(environ, start_response, str(e), 'invalid-password') except KrbPrincipalExpired as e: return self.unauthorized(environ, start_response, str(e), 'krbprincipal-expired') except UserLocked as e: return self.unauthorized(environ, start_response, str(e), 'user-locked') result = self.finalize_kerberos_acquisition('login_password', ipa_ccache_name, environ, start_response) try: # Try not to litter the filesystem with unused TGTs os.unlink(ipa_ccache_name) except OSError: pass return result def kinit(self, user, realm, password, ccache_name): # get anonymous ccache as an armor for FAST to enable OTP auth armor_path = os.path.join(paths.IPA_CCACHES, "armor_{}".format(os.getpid())) self.debug('Obtaining armor in ccache %s', armor_path) try: kinit_armor(armor_path) except RuntimeError as e: self.error("Failed to obtain armor cache") # We try to continue w/o armor, 2FA will be impacted armor_path = None # Format the user as a kerberos principal principal = krb5_format_principal_name(user, realm) try: kinit_password(principal, password, ccache_name, armor_ccache_name=armor_path) if armor_path: self.debug('Cleanup the armor ccache') ipautil.run([paths.KDESTROY, '-A', '-c', armor_path], env={'KRB5CCNAME': armor_path}, raiseonerr=False) except RuntimeError as e: if ('kinit: Cannot read password while ' 'getting initial credentials') in str(e): raise PasswordExpired(principal=principal, message=unicode(e)) elif ('kinit: Client\'s entry in database' ' has expired while getting initial credentials') in str(e): raise KrbPrincipalExpired(principal=principal, message=unicode(e)) elif ('kinit: Clients credentials have been revoked ' 'while getting initial credentials') in str(e): raise UserLocked(principal=principal, message=unicode(e)) raise InvalidSessionPassword(principal=principal, message=unicode(e)) class change_password(Backend, HTTP_Status): content_type = 'text/plain' key = '/session/change_password' def _on_finalize(self): super(change_password, self)._on_finalize() self.api.Backend.wsgi_dispatch.mount(self, self.key) def __call__(self, environ, start_response): self.info('WSGI change_password.__call__:') # Get the user and password parameters from the request content_type = environ.get('CONTENT_TYPE', '').lower() if not content_type.startswith('application/x-www-form-urlencoded'): return self.bad_request(environ, start_response, "Content-Type must be application/x-www-form-urlencoded") method = environ.get('REQUEST_METHOD', '').upper() if method == 'POST': query_string = read_input(environ) else: return self.bad_request(environ, start_response, "HTTP request method must be POST") try: query_dict = parse_qs(query_string) except Exception as e: return self.bad_request(environ, start_response, "cannot parse query data") data = {} for field in ('user', 'old_password', 'new_password', 'otp'): value = query_dict.get(field, None) if value is not None: if len(value) == 1: data[field] = value[0] else: return self.bad_request(environ, start_response, "more than one %s parameter" % field) elif field != 'otp': # otp is optional return self.bad_request(environ, start_response, "no %s specified" % field) # start building the response self.info("WSGI change_password: start password change of user '%s'", data['user']) status = HTTP_STATUS_SUCCESS response_headers = [('Content-Type', 'text/html; charset=utf-8')] title = 'Password change rejected' result = 'error' policy_error = None bind_dn = DN((self.api.Object.user.primary_key.name, data['user']), self.api.env.container_user, self.api.env.basedn) try: pw = data['old_password'] if data.get('otp'): pw = data['old_password'] + data['otp'] conn = ldap2(self.api) conn.connect(bind_dn=bind_dn, bind_pw=pw) except (NotFound, ACIError): result = 'invalid-password' message = 'The old password or username is not correct.' except Exception as e: message = "Could not connect to LDAP server." self.error("change_password: cannot authenticate '%s' to LDAP server: %s", data['user'], str(e)) else: try: conn.modify_password(bind_dn, data['new_password'], data['old_password'], skip_bind=True) except ExecutionError as e: result = 'policy-error' policy_error = escape(str(e)) message = "Password change was rejected: %s" % escape(str(e)) except Exception as e: message = "Could not change the password" self.error("change_password: cannot change password of '%s': %s", data['user'], str(e)) else: result = 'ok' title = "Password change successful" message = "Password was changed." finally: if conn.isconnected(): conn.disconnect() self.info('%s: %s', status, message) response_headers.append(('X-IPA-Pwchange-Result', result)) if policy_error: response_headers.append(('X-IPA-Pwchange-Policy-Error', policy_error)) start_response(status, response_headers) output = _success_template % dict(title=str(title), message=str(message)) return [output] class sync_token(Backend, HTTP_Status): content_type = 'text/plain' key = '/session/sync_token' class OTPSyncRequest(univ.Sequence): OID = "2.16.840.1.113730.3.8.10.6" componentType = namedtype.NamedTypes( namedtype.NamedType('firstCode', univ.OctetString()), namedtype.NamedType('secondCode', univ.OctetString()), namedtype.OptionalNamedType('tokenDN', univ.OctetString()) ) def _on_finalize(self): super(sync_token, self)._on_finalize() self.api.Backend.wsgi_dispatch.mount(self, self.key) def __call__(self, environ, start_response): # Make sure this is a form request. content_type = environ.get('CONTENT_TYPE', '').lower() if not content_type.startswith('application/x-www-form-urlencoded'): return self.bad_request(environ, start_response, "Content-Type must be application/x-www-form-urlencoded") # Make sure this is a POST request. method = environ.get('REQUEST_METHOD', '').upper() if method == 'POST': query_string = read_input(environ) else: return self.bad_request(environ, start_response, "HTTP request method must be POST") # Parse the query string to a dictionary. try: query_dict = parse_qs(query_string) except Exception as e: return self.bad_request(environ, start_response, "cannot parse query data") data = {} for field in ('user', 'password', 'first_code', 'second_code', 'token'): value = query_dict.get(field, None) if value is not None: if len(value) == 1: data[field] = value[0] else: return self.bad_request(environ, start_response, "more than one %s parameter" % field) elif field != 'token': return self.bad_request(environ, start_response, "no %s specified" % field) # Create the request control. sr = self.OTPSyncRequest() sr.setComponentByName('firstCode', data['first_code']) sr.setComponentByName('secondCode', data['second_code']) if 'token' in data: try: token_dn = DN(data['token']) except ValueError: token_dn = DN((self.api.Object.otptoken.primary_key.name, data['token']), self.api.env.container_otp, self.api.env.basedn) sr.setComponentByName('tokenDN', str(token_dn)) rc = ldap.controls.RequestControl(sr.OID, True, encoder.encode(sr)) # Resolve the user DN bind_dn = DN((self.api.Object.user.primary_key.name, data['user']), self.api.env.container_user, self.api.env.basedn) # Start building the response. status = HTTP_STATUS_SUCCESS response_headers = [('Content-Type', 'text/html; charset=utf-8')] title = 'Token sync rejected' # Perform the synchronization. conn = ldap2(self.api) try: conn.connect(bind_dn=bind_dn, bind_pw=data['password'], serverctrls=[rc,]) result = 'ok' title = "Token sync successful" message = "Token was synchronized." except (NotFound, ACIError): result = 'invalid-credentials' message = 'The username, password or token codes are not correct.' except Exception as e: result = 'error' message = "Could not connect to LDAP server." self.error("token_sync: cannot authenticate '%s' to LDAP server: %s", data['user'], str(e)) finally: if conn.isconnected(): conn.disconnect() # Report status and return. response_headers.append(('X-IPA-TokenSync-Result', result)) start_response(status, response_headers) output = _success_template % dict(title=str(title), message=str(message)) return [output] class xmlserver_session(xmlserver, KerberosSession): """ XML RPC server protected with session auth. """ key = '/session/xml' def __init__(self, api): super(xmlserver_session, self).__init__(api) def _on_finalize(self): super(xmlserver_session, self)._on_finalize() def need_login(self, start_response): status = '401 Unauthorized' headers = [] response = b'' self.debug('xmlserver_session: %s need login', status) start_response(status, headers) return [response] def __call__(self, environ, start_response): ''' ''' self.debug('WSGI xmlserver_session.__call__:') ccache_name = environ.get('KRB5CCNAME') # Redirect to /ipa/xml if no Kerberos credentials if ccache_name is None: self.debug('xmlserver_session.__call_: no ccache, need TGT') return self.need_login(start_response) # Redirect to /ipa/xml if Kerberos credentials are expired creds = get_credentials_if_valid(ccache_name=ccache_name) if not creds: self.debug('xmlserver_session.__call_: ccache expired, deleting session, need login') # The request is finished with the ccache, destroy it. return self.need_login(start_response) # Store the session data in the per-thread context setattr(context, 'ccache_name', ccache_name) try: response = super(xmlserver_session, self).__call__(environ, start_response) finally: destroy_context() return response