ansible/roles/koji_hub/files/auth.py
Patrick Uiterwijk b99109eda6 Fix forward-porting
Signed-off-by: Patrick Uiterwijk <patrick@puiterwijk.org>
2019-06-01 22:56:54 +02:00

761 lines
28 KiB
Python

# authentication module
# Copyright (c) 2005-2014 Red Hat, Inc.
#
# Koji is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation;
# version 2.1 of the License.
#
# This software is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this software; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
#
# Authors:
# Mike McLean <mikem@redhat.com>
# Mike Bonnet <mikeb@redhat.com>
from __future__ import absolute_import
import socket
import string
import random
import base64
try:
import krbV
except ImportError:
krbV = None
import koji
from .context import context
from six.moves import range
from six.moves import urllib
from six.moves import zip
import six
from .util import to_list
# 1 - load session if provided
# - check uri for session id
# - load session info from db
# - validate session
# 2 - create a session
# - maybe in two steps
# -
RetryWhitelist = [
'host.taskWait',
'host.taskUnwait',
'host.taskSetWait',
'host.updateHost',
'host.setBuildRootState',
'repoExpire',
'repoDelete',
'repoProblem',
]
class Session(object):
def __init__(self, args=None, hostip=None):
self.logged_in = False
self.id = None
self.master = None
self.key = None
self.user_id = None
self.authtype = None
self.hostip = None
self.user_data = {}
self.message = ''
self.exclusive = False
self.lockerror = None
self.callnum = None
# we look up perms, groups, and host_id on demand, see __getattr__
self._perms = None
self._groups = None
self._host_id = ''
#get session data from request
if args is None:
environ = getattr(context, 'environ', {})
args = environ.get('QUERY_STRING', '')
if not args:
self.message = 'no session args'
return
args = urllib.parse.parse_qs(args, strict_parsing=True)
hostip = self.get_remote_ip(override=hostip)
try:
id = int(args['session-id'][0])
key = args['session-key'][0]
except KeyError as field:
raise koji.AuthError('%s not specified in session args' % field)
try:
callnum = args['callnum'][0]
except:
callnum = None
#lookup the session
c = context.cnx.cursor()
fields = {
'authtype': 'authtype',
'callnum': 'callnum',
'exclusive': 'exclusive',
'expired': 'expired',
'master': 'master',
'start_time': 'start_time',
'update_time': 'update_time',
'EXTRACT(EPOCH FROM start_time)': 'start_ts',
'EXTRACT(EPOCH FROM update_time)': 'update_ts',
'user_id': 'user_id',
}
# sort for stability (unittests)
fields, aliases = zip(*sorted(fields.items(), key=lambda x: x[1]))
q = """
SELECT %s FROM sessions
WHERE id = %%(id)i
AND key = %%(key)s
AND hostip = %%(hostip)s
FOR UPDATE
""" % ",".join(fields)
c.execute(q, locals())
row = c.fetchone()
if not row:
raise koji.AuthError('Invalid session or bad credentials')
session_data = dict(zip(aliases, row))
#check for expiration
if session_data['expired']:
raise koji.AuthExpired('session "%i" has expired' % id)
#check for callnum sanity
if callnum is not None:
try:
callnum = int(callnum)
except (ValueError, TypeError):
raise koji.AuthError("Invalid callnum: %r" % callnum)
lastcall = session_data['callnum']
if lastcall is not None:
if lastcall > callnum:
raise koji.SequenceError("%d > %d (session %d)" \
% (lastcall, callnum, id))
elif lastcall == callnum:
#Some explanation:
#This function is one of the few that performs its own commit.
#However, our storage of the current callnum is /after/ that
#commit. This means the the current callnum only gets committed if
#a commit happens afterward.
#We only schedule a commit for dml operations, so if we find the
#callnum in the db then a previous attempt succeeded but failed to
#return. Data was changed, so we cannot simply try the call again.
method = getattr(context, 'method', 'UNKNOWN')
if method not in RetryWhitelist:
raise koji.RetryError(
"unable to retry call %d (method %s) for session %d" \
% (callnum, method, id))
# read user data
#historical note:
# we used to get a row lock here as an attempt to maintain sanity of exclusive
# sessions, but it was an imperfect approach and the lock could cause some
# performance issues.
fields = ('name', 'status', 'usertype')
q = """SELECT %s FROM users WHERE id=%%(user_id)s""" % ','.join(fields)
c.execute(q, session_data)
user_data = dict(zip(fields, c.fetchone()))
if user_data['status'] != koji.USER_STATUS['NORMAL']:
raise koji.AuthError('logins by %s are not allowed' % user_data['name'])
#check for exclusive sessions
if session_data['exclusive']:
#we are the exclusive session for this user
self.exclusive = True
else:
#see if an exclusive session exists
q = """SELECT id FROM sessions WHERE user_id=%(user_id)s
AND "exclusive" = TRUE AND expired = FALSE"""
#should not return multiple rows (unique constraint)
c.execute(q, session_data)
row = c.fetchone()
if row:
(excl_id,) = row
if excl_id == session_data['master']:
#(note excl_id cannot be None)
#our master session has the lock
self.exclusive = True
else:
#a session unrelated to us has the lock
self.lockerror = "User locked by another session"
# we don't enforce here, but rely on the dispatcher to enforce
# if appropriate (otherwise it would be impossible to steal
# an exclusive session with the force option).
# update timestamp
q = """UPDATE sessions SET update_time=NOW() WHERE id = %(id)i"""
c.execute(q, locals())
#save update time
context.cnx.commit()
#update callnum (this is deliberately after the commit)
#see earlier note near RetryError
if callnum is not None:
q = """UPDATE sessions SET callnum=%(callnum)i WHERE id = %(id)i"""
c.execute(q, locals())
# record the login data
self.id = id
self.key = key
self.hostip = hostip
self.callnum = callnum
self.user_id = session_data['user_id']
self.authtype = session_data['authtype']
self.master = session_data['master']
self.session_data = session_data
self.user_data = user_data
self.logged_in = True
def __getattr__(self, name):
# grab perm and groups data on the fly
if name == 'perms':
if self._perms is None:
#in a dict for quicker lookup
self._perms = dict([[name, 1] for name in get_user_perms(self.user_id)])
return self._perms
elif name == 'groups':
if self._groups is None:
self._groups = get_user_groups(self.user_id)
return self._groups
elif name == 'host_id':
if self._host_id == '':
self._host_id = self._getHostId()
return self._host_id
else:
raise AttributeError("%s" % name)
def __str__(self):
# convenient display for debugging
if not self.logged_in:
s = "session: not logged in"
else:
s = "session %d: %r" % (self.id, self.__dict__)
if self.message:
s += " (%s)" % self.message
return s
def validate(self):
if self.lockerror:
raise koji.AuthLockError(self.lockerror)
return True
def get_remote_ip(self, override=None):
if not context.opts['CheckClientIP']:
return '-'
elif override is not None:
return override
else:
hostip = context.environ['REMOTE_ADDR']
#XXX - REMOTE_ADDR not promised by wsgi spec
if hostip == '127.0.0.1':
hostip = socket.gethostbyname(socket.gethostname())
return hostip
def checkLoginAllowed(self, user_id):
"""Verify that the user is allowed to login"""
cursor = context.cnx.cursor()
query = """SELECT name, usertype, status FROM users WHERE id = %(user_id)i"""
cursor.execute(query, locals())
result = cursor.fetchone()
if not result:
raise koji.AuthError('invalid user_id: %s' % user_id)
name, usertype, status = result
if status != koji.USER_STATUS['NORMAL']:
raise koji.AuthError('logins by %s are not allowed' % name)
def login(self, user, password, opts=None):
"""create a login session"""
if opts is None:
opts = {}
if not isinstance(password, str) or len(password) == 0:
raise koji.AuthError('invalid username or password')
if self.logged_in:
raise koji.GenericError("Already logged in")
hostip = self.get_remote_ip(override=opts.get('hostip'))
# check passwd
c = context.cnx.cursor()
q = """SELECT id FROM users
WHERE name = %(user)s AND password = %(password)s"""
c.execute(q, locals())
r = c.fetchone()
if not r:
raise koji.AuthError('invalid username or password')
user_id = r[0]
self.checkLoginAllowed(user_id)
#create session and return
sinfo = self.createSession(user_id, hostip, koji.AUTHTYPE_NORMAL)
session_id = sinfo['session-id']
context.cnx.commit()
return sinfo
def krbLogin(self, krb_req, proxyuser=None):
"""Authenticate the user using the base64-encoded
AP_REQ message in krb_req. If proxyuser is not None,
log in that user instead of the user associated with the
Kerberos principal. The principal must be an authorized
"proxy_principal" in the server config."""
if self.logged_in:
raise koji.AuthError("Already logged in")
if krbV is None:
# python3 is not supported
raise koji.AuthError("krbV module not installed")
if not (context.opts.get('AuthPrincipal') and context.opts.get('AuthKeytab')):
raise koji.AuthError('not configured for Kerberos authentication')
ctx = krbV.default_context()
srvprinc = krbV.Principal(name=context.opts.get('AuthPrincipal'), context=ctx)
srvkt = krbV.Keytab(name=context.opts.get('AuthKeytab'), context=ctx)
ac = krbV.AuthContext(context=ctx)
ac.flags = krbV.KRB5_AUTH_CONTEXT_DO_SEQUENCE|krbV.KRB5_AUTH_CONTEXT_DO_TIME
conninfo = self.getConnInfo()
ac.addrs = conninfo
# decode and read the authentication request
req = base64.b64decode(krb_req)
ac, opts, sprinc, ccreds = ctx.rd_req(req, server=srvprinc, keytab=srvkt,
auth_context=ac,
options=krbV.AP_OPTS_MUTUAL_REQUIRED)
cprinc = ccreds[2]
# Successfully authenticated via Kerberos, now log in
if proxyuser:
proxyprincs = [princ.strip() for princ in context.opts.get('ProxyPrincipals', '').split(',')]
if cprinc.name in proxyprincs:
login_principal = proxyuser
else:
raise koji.AuthError(
'Kerberos principal %s is not authorized to log in other users' % cprinc.name)
else:
login_principal = cprinc.name
user_id = self.getUserIdFromKerberos(login_principal)
if not user_id:
user_id = self.getUserId(login_principal)
if not user_id:
# Only do autocreate if we also couldn't find by username AND the proxyuser
# looks like a krb5 principal
if context.opts.get('LoginCreatesUser') and '@' in login_principal:
user_id = self.createUserFromKerberos(login_principal)
else:
raise koji.AuthError('Unknown Kerberos principal: %s' % login_principal)
self.checkLoginAllowed(user_id)
hostip = self.get_remote_ip()
sinfo = self.createSession(user_id, hostip, koji.AUTHTYPE_KERB)
# encode the reply
rep = ctx.mk_rep(auth_context=ac)
rep_enc = base64.encodestring(rep)
# encrypt and encode the login info
sinfo_priv = ac.mk_priv('%(session-id)s %(session-key)s' % sinfo)
sinfo_enc = base64.encodestring(sinfo_priv)
return (rep_enc, sinfo_enc, conninfo)
def getConnInfo(self):
"""Return a tuple containing connection information
in the following format:
(local ip addr, local port, remote ip, remote port)"""
# For some reason req.connection.{local,remote}_addr contain port info,
# but no IP info. Use req.connection.{local,remote}_ip for that instead.
# See: http://lists.planet-lab.org/pipermail/devel-community/2005-June/001084.html
# local_ip seems to always be set to the same value as remote_ip,
# so get the local ip via a different method
local_ip = socket.gethostbyname(context.environ['SERVER_NAME'])
remote_ip = context.environ['REMOTE_ADDR']
#XXX - REMOTE_ADDR not promised by wsgi spec
# it appears that calling setports() with *any* value results in authentication
# failing with "Incorrect net address", so return 0 (which prevents
# python-krbV from calling setports())
local_port = 0
remote_port = 0
return (local_ip, local_port, remote_ip, remote_port)
def sslLogin(self, proxyuser=None):
if self.logged_in:
raise koji.AuthError("Already logged in")
if context.environ.get('REMOTE_USER'):
username = context.environ.get('REMOTE_USER')
client_dn = username
authtype = koji.AUTHTYPE_GSSAPI
else:
if context.environ.get('SSL_CLIENT_VERIFY') != 'SUCCESS':
raise koji.AuthError('could not verify client: %s' % context.environ.get('SSL_CLIENT_VERIFY'))
name_dn_component = context.opts.get('DNUsernameComponent', 'CN')
username = context.environ.get('SSL_CLIENT_S_DN_%s' % name_dn_component)
if not username:
raise koji.AuthError('unable to get user information (%s) from client certificate' % name_dn_component)
client_dn = context.environ.get('SSL_CLIENT_S_DN')
authtype = koji.AUTHTYPE_SSL
if proxyuser:
proxy_dns = [dn.strip() for dn in context.opts.get('ProxyDNs', '').split('|')]
if client_dn in proxy_dns:
# the SSL-authenticated user authorized to login other users
username = proxyuser
else:
raise koji.AuthError('%s is not authorized to login other users' % client_dn)
user_id = self.getUserIdFromKerberos(username)
if not user_id:
user_id = self.getUserId(username)
if not user_id:
if context.opts.get('LoginCreatesUser'):
user_id = self.createUser(username)
else:
raise koji.AuthError('Unknown user: %s' % username)
self.checkLoginAllowed(user_id)
hostip = self.get_remote_ip()
sinfo = self.createSession(user_id, hostip, authtype)
return sinfo
def makeExclusive(self, force=False):
"""Make this session exclusive"""
c = context.cnx.cursor()
if self.master is not None:
raise koji.GenericError("subsessions cannot become exclusive")
if self.exclusive:
#shouldn't happen
raise koji.GenericError("session is already exclusive")
user_id = self.user_id
session_id = self.id
#acquire a row lock on the user entry
q = """SELECT id FROM users WHERE id=%(user_id)s FOR UPDATE"""
c.execute(q, locals())
# check that no other sessions for this user are exclusive
q = """SELECT id FROM sessions WHERE user_id=%(user_id)s
AND expired = FALSE AND "exclusive" = TRUE
FOR UPDATE"""
c.execute(q, locals())
row = c.fetchone()
if row:
if force:
#expire the previous exclusive session and try again
(excl_id,) = row
q = """UPDATE sessions SET expired=TRUE,"exclusive"=NULL WHERE id=%(excl_id)s"""
c.execute(q, locals())
else:
raise koji.AuthLockError("Cannot get exclusive session")
#mark this session exclusive
q = """UPDATE sessions SET "exclusive"=TRUE WHERE id=%(session_id)s"""
c.execute(q, locals())
context.cnx.commit()
def makeShared(self):
"""Drop out of exclusive mode"""
c = context.cnx.cursor()
session_id = self.id
q = """UPDATE sessions SET "exclusive"=NULL WHERE id=%(session_id)s"""
c.execute(q, locals())
context.cnx.commit()
def logout(self):
"""expire a login session"""
if not self.logged_in:
#XXX raise an error?
raise koji.AuthError("Not logged in")
update = """UPDATE sessions
SET expired=TRUE,exclusive=NULL
WHERE id = %(id)i OR master = %(id)i"""
#note we expire subsessions as well
c = context.cnx.cursor()
c.execute(update, {'id': self.id})
context.cnx.commit()
self.logged_in = False
def logoutChild(self, session_id):
"""expire a subsession"""
if not self.logged_in:
#XXX raise an error?
raise koji.AuthError("Not logged in")
update = """UPDATE sessions
SET expired=TRUE,exclusive=NULL
WHERE id = %(session_id)i AND master = %(master)i"""
master = self.id
c = context.cnx.cursor()
c.execute(update, locals())
context.cnx.commit()
def createSession(self, user_id, hostip, authtype, master=None):
"""Create a new session for the given user.
Return a map containing the session-id and session-key.
If master is specified, create a subsession
"""
c = context.cnx.cursor()
# generate a random key
alnum = string.ascii_letters + string.digits
key = "%s-%s" %(user_id,
''.join([random.choice(alnum) for x in range(1, 20)]))
# use sha? sha.new(phrase).hexdigest()
# get a session id
q = """SELECT nextval('sessions_id_seq')"""
c.execute(q, {})
(session_id,) = c.fetchone()
#add session id to database
q = """
INSERT INTO sessions (id, user_id, key, hostip, authtype, master)
VALUES (%(session_id)i, %(user_id)i, %(key)s, %(hostip)s, %(authtype)i, %(master)s)
"""
c.execute(q, locals())
context.cnx.commit()
#return session info
return {'session-id' : session_id, 'session-key' : key}
def subsession(self):
"Create a subsession"
if not self.logged_in:
raise koji.AuthError("Not logged in")
master = self.master
if master is None:
master = self.id
return self.createSession(self.user_id, self.hostip, self.authtype,
master=master)
def getPerms(self):
if not self.logged_in:
return []
return to_list(self.perms.keys())
def hasPerm(self, name):
if not self.logged_in:
return False
return name in self.perms
def assertPerm(self, name):
if not self.hasPerm(name) and not self.hasPerm('admin'):
raise koji.ActionNotAllowed("%s permission required" % name)
def assertLogin(self):
if not self.logged_in:
raise koji.ActionNotAllowed("you must be logged in for this operation")
def hasGroup(self, group_id):
if not self.logged_in:
return False
#groups indexed by id
return group_id in self.groups
def isUser(self, user_id):
if not self.logged_in:
return False
return (self.user_id == user_id or self.hasGroup(user_id))
def assertUser(self, user_id):
if not self.isUser(user_id) and not self.hasPerm('admin'):
raise koji.ActionNotAllowed("not owner")
def _getHostId(self):
'''Using session data, find host id (if there is one)'''
if self.user_id is None:
return None
c = context.cnx.cursor()
q = """SELECT id FROM host WHERE user_id = %(uid)d"""
c.execute(q, {'uid' : self.user_id})
r = c.fetchone()
c.close()
if r:
return r[0]
else:
return None
def getHostId(self):
#for compatibility
return self.host_id
def getUserId(self, username):
"""Return the user ID associated with a particular username. If no user
with the given username if found, return None."""
c = context.cnx.cursor()
q = """SELECT id FROM users WHERE name = %(username)s"""
c.execute(q, locals())
r = c.fetchone()
c.close()
if r:
return r[0]
else:
return None
def getUserIdFromKerberos(self, krb_principal):
"""Return the user ID associated with a particular Kerberos principal.
If no user with the given princpal if found, return None."""
c = context.cnx.cursor()
q = """SELECT id FROM users WHERE krb_principal = %(krb_principal)s"""
c.execute(q, locals())
r = c.fetchone()
c.close()
if r:
return r[0]
else:
return None
def createUser(self, name, usertype=None, status=None, krb_principal=None):
"""
Create a new user, using the provided values.
Return the user_id of the newly-created user.
"""
if not name:
raise koji.GenericError('a user must have a non-empty name')
if usertype == None:
usertype = koji.USERTYPES['NORMAL']
elif not koji.USERTYPES.get(usertype):
raise koji.GenericError('invalid user type: %s' % usertype)
if status == None:
status = koji.USER_STATUS['NORMAL']
elif not koji.USER_STATUS.get(status):
raise koji.GenericError('invalid status: %s' % status)
cursor = context.cnx.cursor()
select = """SELECT nextval('users_id_seq')"""
cursor.execute(select, locals())
user_id = cursor.fetchone()[0]
insert = """INSERT INTO users (id, name, usertype, status, krb_principal)
VALUES (%(user_id)i, %(name)s, %(usertype)i, %(status)i, %(krb_principal)s)"""
cursor.execute(insert, locals())
context.cnx.commit()
return user_id
def setKrbPrincipal(self, name, krb_principal):
usertype = koji.USERTYPES['NORMAL']
status = koji.USER_STATUS['NORMAL']
update = """UPDATE users SET krb_principal = %(krb_principal)s WHERE name = %(name)s AND usertype = %(usertype)i AND status = %(status)i RETURNING users.id"""
cursor = context.cnx.cursor()
cursor.execute(update, locals())
r = cursor.fetchall()
if len(r) != 1:
context.cnx.rollback()
raise koji.AuthError('could not automatically associate Kerberos Principal with existing user %s' % name)
else:
context.cnx.commit()
return r[0][0]
def createUserFromKerberos(self, krb_principal):
"""Create a new user, based on the Kerberos principal. Their
username will be everything before the "@" in the principal.
Return the ID of the newly created user."""
atidx = krb_principal.find('@')
if atidx == -1:
raise koji.AuthError('invalid Kerberos principal: %s' % krb_principal)
user_name = krb_principal[:atidx]
# check if user already exists
c = context.cnx.cursor()
q = """SELECT krb_principal FROM users
WHERE name = %(user_name)s"""
c.execute(q, locals())
r = c.fetchone()
if not r:
return self.createUser(user_name, krb_principal=krb_principal)
else:
existing_user_krb = r[0]
if existing_user_krb is not None:
raise koji.AuthError('user %s already associated with other Kerberos principal: %s' % (user_name, existing_user_krb))
return self.setKrbPrincipal(user_name, krb_principal)
def get_user_groups(user_id):
"""Get user groups
returns a dictionary where the keys are the group ids and the values
are the group names"""
c = context.cnx.cursor()
t_group = koji.USERTYPES['GROUP']
q = """SELECT group_id,name
FROM user_groups JOIN users ON group_id = users.id
WHERE active = TRUE AND users.usertype=%(t_group)i
AND user_id=%(user_id)i"""
c.execute(q, locals())
return dict(c.fetchall())
def get_user_perms(user_id):
c = context.cnx.cursor()
q = """SELECT name
FROM user_perms JOIN permissions ON perm_id = permissions.id
WHERE active = TRUE AND user_id=%(user_id)s"""
c.execute(q, locals())
#return a list of permissions by name
return [row[0] for row in c.fetchall()]
def get_user_data(user_id):
c = context.cnx.cursor()
fields = ('name', 'status', 'usertype')
q = """SELECT %s FROM users WHERE id=%%(user_id)s""" % ','.join(fields)
c.execute(q, locals())
row = c.fetchone()
if not row:
return None
return dict(zip(fields, row))
def login(*args, **opts):
return context.session.login(*args, **opts)
def krbLogin(*args, **opts):
return context.session.krbLogin(*args, **opts)
def sslLogin(*args, **opts):
return context.session.sslLogin(*args, **opts)
def logout():
return context.session.logout()
def subsession():
return context.session.subsession()
def logoutChild(session_id):
return context.session.logoutChild(session_id)
def exclusiveSession(*args, **opts):
"""Make this session exclusive"""
return context.session.makeExclusive(*args, **opts)
def sharedSession():
"""Drop out of exclusive mode"""
return context.session.makeShared()
if __name__ == '__main__': # pragma: no cover
# XXX - testing defaults
import db
db.setDBopts(database="test", user="test")
print("Connecting to db")
context.cnx = db.connect()
print("starting session 1")
sess = Session(None, hostip='127.0.0.1')
print("Session 1: %s" % sess)
print("logging in with session 1")
session_info = sess.login('host/1', 'foobar', {'hostip':'127.0.0.1'})
#wrap values in lists
session_info = dict([[k, [v]] for k, v in six.iteritems(session_info)])
print("Session 1: %s" % sess)
print("Session 1 info: %r" % session_info)
print("Creating session 2")
s2 = Session(session_info, '127.0.0.1')
print("Session 2: %s " % s2)