mirror of
https://salsa.debian.org/freeipa-team/freeipa.git
synced 2024-12-30 10:47:08 -06:00
70889d4d5e
When mod_lookup_identity is unable to match user by certificate (and username) it unsets http request's user. mod_auth_gssapi is then unable to get Kerberos ticket and doesn't set KRB5CCNAME environment variable. x509_login.__call__ now returns 401 in such case to indicate that request was not authenticated. https://pagure.io/freeipa/issue/6225 Reviewed-By: Florence Blanc-Renaud <frenaud@redhat.com>
1225 lines
42 KiB
Python
1225 lines
42 KiB
Python
# Authors:
|
|
# Jason Gerard DeRose <jderose@redhat.com>
|
|
#
|
|
# 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 <http://www.gnu.org/licenses/>.
|
|
|
|
"""
|
|
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 ipapython.dn import DN
|
|
from ipaserver.plugins.ldap2 import ldap2
|
|
from ipalib.backend import Backend
|
|
from ipalib.krb_utils import (
|
|
get_credentials_if_valid)
|
|
from ipapython import kerberos
|
|
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 = """<html>
|
|
<head>
|
|
<title>404 Not Found</title>
|
|
</head>
|
|
<body>
|
|
<h1>Not Found</h1>
|
|
<p>
|
|
The requested URL <strong>%(url)s</strong> was not found on this server.
|
|
</p>
|
|
</body>
|
|
</html>"""
|
|
|
|
_bad_request_template = """<html>
|
|
<head>
|
|
<title>400 Bad Request</title>
|
|
</head>
|
|
<body>
|
|
<h1>Bad Request</h1>
|
|
<p>
|
|
<strong>%(message)s</strong>
|
|
</p>
|
|
</body>
|
|
</html>"""
|
|
|
|
_internal_error_template = """<html>
|
|
<head>
|
|
<title>500 Internal Server Error</title>
|
|
</head>
|
|
<body>
|
|
<h1>Internal Server Error</h1>
|
|
<p>
|
|
<strong>%(message)s</strong>
|
|
</p>
|
|
</body>
|
|
</html>"""
|
|
|
|
_unauthorized_template = """<html>
|
|
<head>
|
|
<title>401 Unauthorized</title>
|
|
</head>
|
|
<body>
|
|
<h1>Invalid Authentication</h1>
|
|
<p>
|
|
<strong>%(message)s</strong>
|
|
</p>
|
|
</body>
|
|
</html>"""
|
|
|
|
_success_template = """<html>
|
|
<head>
|
|
<title>200 Success</title>
|
|
</head>
|
|
<body>
|
|
<h1>%(title)s</h1>
|
|
<p>
|
|
<strong>%(message)s</strong>
|
|
</p>
|
|
</body>
|
|
</html>"""
|
|
|
|
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')]
|
|
|
|
logout_cookie = getattr(context, 'logout_cookie', None)
|
|
if logout_cookie is not None:
|
|
headers.append(('IPASESSION', logout_cookie))
|
|
|
|
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, pretty_print=self.api.env.debug >= 2
|
|
)
|
|
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')
|
|
|
|
object.__setattr__(
|
|
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())
|
|
|
|
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'
|
|
|
|
def __call__(self, environ, start_response):
|
|
self.debug('WSGI login_x509.__call__:')
|
|
|
|
if 'KRB5CCNAME' not in environ:
|
|
return self.unauthorized(
|
|
environ, start_response, 'KRB5CCNAME not set',
|
|
'Authentication failed')
|
|
|
|
super(login_x509, self).__call__(environ, start_response)
|
|
|
|
|
|
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
|
|
# we kinit as enterprise principal so we can assume that unknown realms
|
|
# are UPN
|
|
try:
|
|
user_principal = kerberos.Principal(user)
|
|
except Exception:
|
|
# the principal is malformed in some way (e.g. user@REALM1@REALM2)
|
|
# netbios names (NetBIOS1\user) are also not accepted (yet)
|
|
return self.unauthorized(environ, start_response, '', 'denied')
|
|
|
|
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_principal, 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, principal, 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
|
|
|
|
try:
|
|
kinit_password(
|
|
unicode(principal),
|
|
password,
|
|
ccache_name,
|
|
armor_ccache_name=armor_path,
|
|
enterprise=True)
|
|
|
|
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
|