Implement password based session login

* Adjust URL's
  - rename /ipa/login -> /ipa/session/login_kerberos
  - add /ipa/session/login_password

* Adjust Kerberos protection on URL's in ipa.conf

* Bump VERSION in httpd ipa.conf to pick up session changes.

* Adjust login URL in ipa.js

* Add InvalidSessionPassword to errors.py

* Rename krblogin class to login_kerberos for consistency with
  new login_password class

* Implement login_password.kinit() method which invokes
  /usr/bin/kinit as a subprocess

* Add login_password class for WSGI dispatch, accepts POST
  application/x-www-form-urlencoded user & password
  parameters. We form the Kerberos principal from the server's
  realm.

* Add function  krb5_unparse_ccache()

* Refactor code to share common code

* Clean up use of ccache names, be consistent

* Replace read_krbccache_file(), store_krbccache_file(), delete_krbccache_file()
  with load_ccache_data(), bind_ipa_ccache(), release_ipa_ccache().
  bind_ipa_ccache() now sets environment KRB5CCNAME variable.
  release_ipa_ccache() now clears environment KRB5CCNAME variable.

* ccache names should now support any ccache storage scheme,
  not just FILE based ccaches

* Add utilies to return HTTP status from wsgi handlers,
  use constants for HTTP status code for consistency.
  Use utilies for returning from wsgi handlers rather than
  duplicated code.

* Add KerberosSession.finalize_kerberos_acquisition() method
  so different login handlers can share common code.

* add Requires: krb5-workstation to server (server now calls kinit)

* Fix test_rpcserver.py to use new dispatch inside route() method

https://fedorahosted.org/freeipa/ticket/2095
This commit is contained in:
John Dennis 2012-02-25 13:39:19 -05:00 committed by Rob Crittenden
parent 059a90702e
commit ee780df13c
8 changed files with 290 additions and 94 deletions

View File

@ -1,5 +1,5 @@
#
# VERSION 3 - DO NOT REMOVE THIS LINE
# VERSION 4 - DO NOT REMOVE THIS LINE
#
# LoadModule auth_kerb_module modules/mod_auth_kerb.so
@ -60,7 +60,13 @@ KrbConstrainedDelegationLock ipa
</Location>
# Turn off Apache authentication for sessions
<Location "/ipa/session">
<Location "/ipa/session/json">
Satisfy Any
Order Deny,Allow
Allow from all
</Location>
<Location "/ipa/session/login_password">
Satisfy Any
Order Deny,Allow
Allow from all

View File

@ -60,7 +60,7 @@ var IPA = function() {
// if current path matches live server path, use live data
if (that.url && window.location.pathname.substring(0, that.url.length) === that.url) {
that.json_url = params.url || '/ipa/session/json';
that.login_url = params.url || '/ipa/login';
that.login_url = params.url || '/ipa/session/login_kerberos';
} else { // otherwise use fixtures
that.json_path = params.url || "test/data";

View File

@ -612,6 +612,13 @@ class SessionError(AuthenticationError):
format= _('Session error')
class InvalidSessionPassword(SessionError):
"""
**1201** Raised when we cannot obtain a TGT for a principal.
"""
errno = 1201
format= _('Principal %(principal)s cannot be authenticated: %(message)s')
##############################################################################
# 2000 - 2999: Authorization errors
class AuthorizationError(PublicError):

View File

@ -42,7 +42,7 @@ ccache_name_re = re.compile(r'^((\w+):)?(.+)')
#-------------------------------------------------------------------------------
def krb5_parse_ccache(name):
def krb5_parse_ccache(ccache_name):
'''
Given a Kerberos ccache name parse it into it's scheme and
location components. Currently valid values for the scheme
@ -55,12 +55,12 @@ def krb5_parse_ccache(name):
does not exist it defaults to FILE.
:parameters:
name
ccache_name
The name of the Kerberos ccache.
:returns:
A two-tuple of (scheme, ccache)
'''
match = ccache_name_re.search(name)
match = ccache_name_re.search(ccache_name)
if match:
scheme = match.group(2)
location = match.group(3)
@ -71,7 +71,10 @@ def krb5_parse_ccache(name):
return scheme, location
else:
raise ValueError('Invalid ccache name = "%s"' % name)
raise ValueError('Invalid ccache name = "%s"' % ccache_name)
def krb5_unparse_ccache(scheme, name):
return '%s:%s' % (scheme.upper(), name)
def krb5_format_principal_name(user, realm):
'''
@ -388,5 +391,5 @@ class KRB5_CCache(object):
except KeyError:
pass
self.debug('"%s" ccache endtime=%s (%s)', self.ccache_str(), result, krb5_format_time(result))
self.debug('KRB5_CCache %s endtime=%s (%s)', self.ccache_str(), result, krb5_format_time(result))
return result

View File

@ -1197,34 +1197,71 @@ class MemcacheSessionManager(SessionManager):
krbccache_dir ='/var/run/ipa_memcached'
krbccache_prefix = 'krbcc_'
def get_krbccache_pathname():
def _get_krbccache_pathname():
return os.path.join(krbccache_dir, '%s%s' % (krbccache_prefix, os.getpid()))
def read_krbccache_file(krbccache_pathname):
root_logger.debug('reading krbccache data from "%s"', krbccache_pathname)
src = open(krbccache_pathname)
ccache_data = src.read()
src.close()
return ccache_data
def get_ipa_ccache_name(scheme='FILE'):
if scheme == 'FILE':
name = os.path.join(krbccache_dir, '%s%s' % (krbccache_prefix, os.getpid()))
else:
raise ValueError('ccache scheme "%s" unsupported', scheme)
def store_krbccache_file(ccache_data):
krbccache_pathname = get_krbccache_pathname()
root_logger.debug('storing krbccache data into "%s"', krbccache_pathname)
dst = open(krbccache_pathname, 'w')
dst.write(ccache_data)
dst.close()
ccache_name = krb5_unparse_ccache(scheme, name)
return ccache_name
return krbccache_pathname
def delete_krbccache_file(krbccache_pathname=None):
if krbccache_pathname is None:
krbccache_pathname = get_krbccache_pathname()
def load_ccache_data(ccache_name):
scheme, name = krb5_parse_ccache(ccache_name)
if scheme == 'FILE':
root_logger.debug('reading ccache data from file "%s"', name)
src = open(name)
ccache_data = src.read()
src.close()
return ccache_data
else:
raise ValueError('ccache scheme "%s" unsupported (%s)', scheme, ccache_name)
try:
os.unlink(krbccache_pathname)
except Exception, e:
root_logger.error('unable to delete session krbccache file "%s", %s',
krbccache_pathname, e)
def bind_ipa_ccache(ccache_data, scheme='FILE'):
if scheme == 'FILE':
name = _get_krbccache_pathname()
root_logger.debug('storing ccache data into file "%s"', name)
dst = open(name, 'w')
dst.write(ccache_data)
dst.close()
else:
raise ValueError('ccache scheme "%s" unsupported', scheme)
ccache_name = krb5_unparse_ccache(scheme, name)
os.environ['KRB5CCNAME'] = ccache_name
return ccache_name
def release_ipa_ccache(ccache_name):
'''
Stop using the current request's ccache.
* Remove KRB5CCNAME from the enviroment
* Remove the ccache file from the file system
Note, we do not demand any of these elements exist, but if they
do we'll remove them.
'''
if os.environ.has_key('KRB5CCNAME'):
if ccache_name != os.environ['KRB5CCNAME']:
root_logger.error('release_ipa_ccache: ccache_name (%s) != KRB5CCNAME environment variable (%s)',
ccache_name, os.environ['KRB5CCNAME'])
del os.environ['KRB5CCNAME']
else:
root_logger.debug('release_ipa_ccache: KRB5CCNAME environment variable not set')
scheme, name = krb5_parse_ccache(ccache_name)
if scheme == 'FILE':
if os.path.exists(name):
try:
os.unlink(name)
except Exception, e:
root_logger.error('unable to delete session ccache file "%s", %s', name, e)
else:
raise ValueError('ccache scheme "%s" unsupported (%s)', scheme, ccache_name)
#-------------------------------------------------------------------------------

View File

@ -25,9 +25,10 @@ Loads WSGI server plugins.
from ipalib import api
if 'in_server' in api.env and api.env.in_server is True:
from ipaserver.rpcserver import wsgi_dispatch, xmlserver, jsonserver_kerb, jsonserver_session, krblogin
from ipaserver.rpcserver import wsgi_dispatch, xmlserver, jsonserver_kerb, jsonserver_session, login_kerberos, login_password
api.register(wsgi_dispatch)
api.register(xmlserver)
api.register(jsonserver_kerb)
api.register(jsonserver_session)
api.register(krblogin)
api.register(login_kerberos)
api.register(login_password)

View File

@ -27,14 +27,15 @@ from cgi import parse_qs
from xml.sax.saxutils import escape
from xmlrpclib import Fault
from ipalib.backend import Executioner
from ipalib.errors import PublicError, InternalError, CommandError, JSONError, ConversionError, CCacheError, RefererError
from ipalib.errors import PublicError, InternalError, CommandError, JSONError, ConversionError, CCacheError, RefererError, InvalidSessionPassword
from ipalib.request import context, Connection, destroy_context
from ipalib.rpc import xml_dumps, xml_loads
from ipalib.util import make_repr, parse_time_duration
from ipapython.compat import json
from ipalib.session import session_mgr, AuthManager, read_krbccache_file, store_krbccache_file, delete_krbccache_file, fmt_time, default_max_session_duration
from ipalib.session import session_mgr, AuthManager, get_ipa_ccache_name, load_ccache_data, bind_ipa_ccache, release_ipa_ccache, fmt_time, default_max_session_duration
from ipalib.backend import Backend
from ipalib.krb_utils import krb5_parse_ccache, KRB5_CCache, krb_ticket_expiration_threshold
from ipalib.krb_utils import krb5_parse_ccache, KRB5_CCache, krb_ticket_expiration_threshold, krb5_format_principal_name
from ipapython import ipautil
from wsgiref.util import shift_path_info
from ipapython.version import VERSION
import base64
@ -42,6 +43,11 @@ import os
import string
import datetime
from decimal import Decimal
import urlparse
HTTP_STATUS_SUCCESS = '200 Success'
HTTP_STATUS_SERVER_ERROR = '500 Internal Server Error'
_not_found_template = """<html>
<head>
<title>404 Not Found</title>
@ -54,19 +60,84 @@ The requested URL <strong>%(url)s</strong> was not found on this server.
</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>"""
def not_found(environ, start_response):
"""
Return a 404 Not Found error.
"""
status = '404 Not Found'
response_headers = [('Content-Type', 'text/html')]
response_headers = [('Content-Type', 'text/html; charset=utf-8')]
start_response(status, response_headers)
output = _not_found_template % dict(
url=escape(environ['SCRIPT_NAME'] + environ['PATH_INFO'])
)
return [output]
def bad_request(environ, start_response, message):
"""
Return a 400 Bad Request error.
"""
status = '400 Bad Request'
response_headers = [('Content-Type', 'text/html; charset=utf-8')]
start_response(status, response_headers)
output = _bad_request_template % dict(message=escape(message))
return [output]
def internal_error(environ, start_response, message):
"""
Return a 500 Internal Server Error.
"""
status = HTTP_STATUS_SERVER_ERROR
response_headers = [('Content-Type', 'text/html; charset=utf-8')]
start_response(status, response_headers)
output = _internal_error_template % dict(message=escape(message))
return [output]
def unauthorized(environ, start_response, message):
"""
Return a 401 Unauthorized error.
"""
status = '401 Unauthorized'
response_headers = [('Content-Type', 'text/html; charset=utf-8')]
start_response(status, response_headers)
output = _unauthorized_template % dict(message=escape(message))
return [output]
def read_input(environ):
"""
Read the request body from environ['wsgi.input'].
@ -267,14 +338,14 @@ class WSGIExecutioner(Executioner):
self.debug('WSGI WSGIExecutioner.__call__:')
try:
status = '200 OK'
status = HTTP_STATUS_SUCCESS
response = self.wsgi_execute(environ)
headers = [('Content-Type', self.content_type + '; charset=utf-8')]
except StandardError, e:
self.exception('WSGI %s.__call__():', self.name)
status = '500 Internal Server Error'
status = HTTP_STATUS_SERVER_ERROR
response = status
headers = [('Content-Type', 'text/plain')]
headers = [('Content-Type', 'text/plain; charset=utf-8')]
session_data = getattr(context, 'session_data', None)
if session_data is not None:
@ -316,17 +387,16 @@ class xmlserver(WSGIExecutioner):
'''
self.debug('WSGI xmlserver.__call__:')
ccache=environ.get('KRB5CCNAME')
if ccache is None:
user_ccache=environ.get('KRB5CCNAME')
if user_ccache is None:
return self.marshal(None, CCacheError())
self.create_context(ccache=ccache)
try:
self.create_context(ccache=environ.get('KRB5CCNAME'))
self.create_context(ccache=user_ccache)
response = super(xmlserver, self).__call__(environ, start_response)
except PublicError, e:
status = '200 OK'
status = HTTP_STATUS_SUCCESS
response = status
headers = [('Content-Type', 'text/plain')]
headers = [('Content-Type', 'text/plain; charset=utf-8')]
start_response(status, headers)
return self.marshal(None, e)
finally:
@ -619,6 +689,40 @@ class KerberosSession(object):
max_age=krb_expiration,
duration_type=self.api.env.session_duration_type)
def finalize_kerberos_acquisition(self, who, ccache_name, environ, start_response, headers=None):
if headers is None:
headers = []
# Retrieve the session data (or newly create)
session_data = session_mgr.load_session_data(environ.get('HTTP_COOKIE'))
session_id = session_data['session_id']
self.debug('finalize_kerberos_acquisition: %s ccache_name="%s" session_id="%s"',
who, ccache_name, session_id)
# Copy the ccache file contents into the session data
session_data['ccache_data'] = load_ccache_data(ccache_name)
# Set when the session will expire
cc = KRB5_CCache(ccache_name)
endtime = cc.endtime(self.api.env.host, self.api.env.realm)
self.update_session_expiration(session_data, endtime)
# Store the session data now that it's been updated with the ccache
session_mgr.store_session_data(session_data)
# The request is finished with the ccache, destroy it.
release_ipa_ccache(ccache_name)
# Return success and set session cookie
session_cookie = session_mgr.generate_cookie('/ipa', session_id)
headers.append(('Set-Cookie', session_cookie))
start_response(HTTP_STATUS_SUCCESS, headers)
return ['']
class jsonserver_session(jsonserver, KerberosSession):
"""
JSON RPC server protected with session auth.
@ -668,13 +772,14 @@ class jsonserver_session(jsonserver, KerberosSession):
self.debug('no ccache, need login')
return self.need_login(start_response)
krbccache_pathname = store_krbccache_file(ccache_data)
ipa_ccache_name = bind_ipa_ccache(ccache_data)
# Redirect to login if Kerberos credentials are expired
cc = KRB5_CCache(krbccache_pathname)
cc = KRB5_CCache(ipa_ccache_name)
if not cc.valid(self.api.env.host, self.api.env.realm):
self.debug('ccache expired, deleting session, need login')
delete_krbccache_file(krbccache_pathname)
# The request is finished with the ccache, destroy it.
release_ipa_ccache(ipa_ccache_name)
return self.need_login(start_response)
# Update the session expiration based on the Kerberos expiration
@ -684,7 +789,7 @@ class jsonserver_session(jsonserver, KerberosSession):
# Store the session data in the per-thread context
setattr(context, 'session_data', session_data)
self.create_context(ccache=krbccache_pathname)
self.create_context(ccache=ipa_ccache_name)
try:
response = super(jsonserver_session, self).__call__(environ, start_response)
@ -701,10 +806,10 @@ class jsonserver_session(jsonserver, KerberosSession):
# data to invalidate the session credentials.
if session_data.has_key('ccache_data'):
session_data['ccache_data'] = read_krbccache_file(krbccache_pathname)
session_data['ccache_data'] = load_ccache_data(ipa_ccache_name)
# Delete the temporary ccache file we used
delete_krbccache_file(krbccache_pathname)
# The request is finished with the ccache, destroy it.
release_ipa_ccache(ipa_ccache_name)
# Store the session data.
session_mgr.store_session_data(session_data)
destroy_context()
@ -724,10 +829,10 @@ class jsonserver_kerb(jsonserver):
self.debug('WSGI jsonserver_kerb.__call__:')
ccache=environ.get('KRB5CCNAME')
if ccache is None:
user_ccache=environ.get('KRB5CCNAME')
if user_ccache is None:
return self.marshal(None, CCacheError())
self.create_context(ccache=ccache)
self.create_context(ccache=user_ccache)
try:
response = super(jsonserver_kerb, self).__call__(environ, start_response)
@ -737,59 +842,96 @@ class jsonserver_kerb(jsonserver):
return response
class krblogin(Backend, KerberosSession):
key = '/login'
class login_kerberos(Backend, KerberosSession):
key = '/session/login_kerberos'
def __init__(self):
super(krblogin, self).__init__()
super(login_kerberos, self).__init__()
def _on_finalize(self):
super(krblogin, self)._on_finalize()
super(login_kerberos, self)._on_finalize()
self.api.Backend.wsgi_dispatch.mount(self, self.key)
self.kerb_session_on_finalize()
def __call__(self, environ, start_response):
headers = []
self.debug('WSGI krblogin.__call__:')
self.debug('WSGI login_kerberos.__call__:')
# Get the ccache created by mod_auth_kerb
ccache=environ.get('KRB5CCNAME')
if ccache is None:
status = '500 Internal Error'
response = 'KRB5CCNAME not defined'
start_response(status, headers)
return [response]
user_ccache_name=environ.get('KRB5CCNAME')
if user_ccache_name is None:
return internal_error(environ, start_response, 'KRB5CCNAME not defined')
ccache_scheme, ccache_location = krb5_parse_ccache(ccache)
assert ccache_scheme == 'FILE'
return self.finalize_kerberos_acquisition('login_kerberos', user_ccache_name, environ, start_response)
# Retrieve the session data (or newly create)
session_data = session_mgr.load_session_data(environ.get('HTTP_COOKIE'))
session_id = session_data['session_id']
class login_password(Backend, KerberosSession):
# Copy the ccache file contents into the session data
session_data['ccache_data'] = read_krbccache_file(ccache_location)
content_type = 'text/plain'
key = '/session/login_password'
# Set when the session will expire
cc = KRB5_CCache(ccache)
endtime = cc.endtime(self.api.env.host, self.api.env.realm)
self.update_session_expiration(session_data, endtime)
def __init__(self):
super(login_password, self).__init__()
# Store the session data now that it's been updated with the ccache
session_mgr.store_session_data(session_data)
def _on_finalize(self):
super(login_password, self)._on_finalize()
self.api.Backend.wsgi_dispatch.mount(self, self.key)
self.kerb_session_on_finalize()
self.debug('krblogin: ccache="%s" session_id="%s" ccache="%s"',
ccache, session_id, ccache)
def __call__(self, environ, start_response):
self.debug('WSGI login_password.__call__:')
# Return success and set session cookie
status = '200 Success'
response = ''
# Get the user and password parameters from the request
content_type = environ.get('CONTENT_TYPE', '').lower()
if content_type != 'application/x-www-form-urlencoded':
return bad_request(environ, start_response, "Content-Type must be application/x-www-form-urlencoded")
session_cookie = session_mgr.generate_cookie('/ipa', session_id)
headers.append(('Set-Cookie', session_cookie))
method = environ.get('REQUEST_METHOD', '').upper()
if method == 'POST':
query_string = read_input(environ)
else:
return bad_request(environ, start_response, "HTTP request method must be POST")
start_response(status, headers)
return [response]
try:
query_dict = urlparse.parse_qs(query_string)
except Exception, e:
return 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 bad_request(environ, start_response, "more than one user parameter")
else:
return bad_request(environ, start_response, "no user specified")
password = query_dict.get('password', None)
if password is not None:
if len(password) == 1:
password = password[0]
else:
return bad_request(environ, start_response, "more than one password parameter")
else:
return 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 = get_ipa_ccache_name()
try:
self.kinit(user, self.api.env.realm, password, ipa_ccache_name)
except InvalidSessionPassword, e:
return unauthorized(environ, start_response, str(e))
return self.finalize_kerberos_acquisition('login_password', ipa_ccache_name, environ, start_response)
def kinit(self, user, realm, password, ccache_name):
# Format the user as a kerberos principal
principal = krb5_format_principal_name(user, realm)
(stdout, stderr, returncode) = ipautil.run(['/usr/bin/kinit', principal],
env={'KRB5CCNAME':ccache_name},
stdin=password, raiseonerr=False)
self.debug('kinit: principal=%s returncode=%s, stderr="%s"',
principal, returncode, stderr)
if returncode != 0:
raise InvalidSessionPassword(principal=principal, message=unicode(stderr))

View File

@ -100,14 +100,14 @@ class test_session(object):
)
inst = self.klass()
inst.mount(app1, 'foo')
inst.mount(app2, 'bar')
inst.mount(app1, '/foo/stuff')
inst.mount(app2, '/bar')
d = dict(SCRIPT_NAME='/ipa', PATH_INFO='/foo/stuff')
assert inst.route(d, None) == ('from 1', ['/ipa/foo', '/stuff'])
assert inst.route(d, None) == ('from 1', ['/ipa', '/foo/stuff'])
d = dict(SCRIPT_NAME='/ipa', PATH_INFO='/bar')
assert inst.route(d, None) == ('from 2', ['/ipa/bar', ''])
assert inst.route(d, None) == ('from 2', ['/ipa', '/bar'])
def test_mount(self):
def app1(environ, start_response):