Skip to content

Commit

Permalink
Merge pull request #52 from alanbriolat/plugin/auth
Browse files Browse the repository at this point in the history
User tracking and permissions/authentication
  • Loading branch information
alanbriolat committed Oct 21, 2013
2 parents 7a9a5a2 + 9cc61a3 commit e48b79d
Show file tree
Hide file tree
Showing 13 changed files with 638 additions and 30 deletions.
7 changes: 6 additions & 1 deletion csbot.deploy.cfg
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
[@bot]
nickname = Mathison
channels = #cs-york #cs-york-dev
plugins = logger linkinfo hoogle imgur csyork
plugins = logger linkinfo hoogle imgur csyork auth

[linkinfo]
scan_limit = 2

[auth]
@everything = * *:*
Alan = @everything
Haegin = @everything
52 changes: 46 additions & 6 deletions csbot/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ def __init__(self, config=None):
# Load configuration
self.config_root = configparser.ConfigParser(interpolation=None,
allow_no_value=True)
self.config_root.optionxform = str # No lowercase option names
if config is not None:
self.config_root.read_file(config)

Expand Down Expand Up @@ -164,6 +165,8 @@ class PluginError(Exception):
class BotProtocol(irc.IRCClient):
log = logging.getLogger('csbot.protocol')

_WHO_IDENTIFY = ('1', '%na')

def __init__(self, bot):
self.bot = bot
# Get IRCClient configuration from the Bot
Expand Down Expand Up @@ -192,6 +195,7 @@ def emit(self, event):

def connectionMade(self):
irc.IRCClient.connectionMade(self)
self.sendLine('CAP REQ :account-notify extended-join')
self.emit_new('core.raw.connected')

def connectionLost(self, reason):
Expand Down Expand Up @@ -219,6 +223,7 @@ def signedOn(self):
self.emit_new('core.self.connected')

def joined(self, channel):
self.identify(channel)
self.emit_new('core.self.joined', {'channel': channel})

def left(self, channel):
Expand Down Expand Up @@ -251,11 +256,24 @@ def action(self, user, channel, message):
'reply_to': nick(user) if channel == self.nickname else channel,
})

def userJoined(self, user, channel):
self.emit_new('core.channel.joined', {
'channel': channel,
'user': user,
})
def irc_JOIN(self, prefix, params):
"""Re-implement ``JOIN`` handler to account for ``extended-join`` info.
"""
user = prefix
nick_ = nick(user)
channel, account, _ = params

if nick_ == self.nickname:
self.joined(channel)
else:
self.emit_new('core.user.identified', {
'user': user,
'account': None if account == '*' else account,
})
self.emit_new('core.channel.joined', {
'channel': channel,
'user': user,
})

def userLeft(self, user, channel):
self.emit_new('core.channel.left', {
Expand Down Expand Up @@ -315,6 +333,28 @@ def topicUpdated(self, user, channel, newtopic):
'topic': newtopic,
})

def identify(self, target):
"""Find the account for a user or all users in a channel."""
tag, query = self._WHO_IDENTIFY
self.sendLine('WHO {} {}t,{}'.format(target, query, tag))
pass

def irc_354(self, prefix, params):
"""Handle "formatted WHO" responses."""
tag = params[1]
if tag == self._WHO_IDENTIFY[0]:
self.emit_new('core.user.identified', {
'user': params[2],
'account': None if params[3] == '0' else params[3],
})

def irc_ACCOUNT(self, prefix, params):
"""Account change notification from ``account-notify`` capability."""
self.emit_new('core.user.identified', {
'user': prefix,
'account': None if params[0] == '*' else params[0],
})


class BotFactory(protocol.ClientFactory):
def __init__(self, bot):
Expand Down Expand Up @@ -368,7 +408,7 @@ def main(argv):
handler.setLevel(args.loglevel)
handler.addFilter(ColorLogFilter())
handler.setFormatter(logging.Formatter(
('\x1b[%(color)sm[%(asctime)s] (%(levelname).1s:%(name)s)'
('\x1b[%(color)sm[%(asctime)s] (%(levelname).1s:%(name)s) '
'%(message)s\x1b[0m'),
'%Y/%m/%d %H:%M:%S'))
rootlogger = logging.getLogger('')
Expand Down
106 changes: 106 additions & 0 deletions csbot/plugins/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
from collections import defaultdict

from csbot.plugin import Plugin


class PermissionDB(defaultdict):
"""A helper class for assembling the permissions database."""
def __init__(self):
super(PermissionDB, self).__init__(set)
self._groups = defaultdict(set)
self._current = None

def process(self, entity, permissions):
"""Process a configuration entry, where *entity* is an account name,
``@group`` name or ``*`` and *permissions* is a space-separated list of
permissions to grant.
"""
self._current = (entity, permissions)

if entity.startswith('@'):
if entity in self._groups:
self._error('Group "{}" already defined'.format(entity))
entity_perms = self._groups[entity]
else:
entity_perms = self[entity]

for permission in permissions.split():
if ':' in permission:
self._add_channel_permissions(entity_perms, permission)
elif permission.startswith('@'):
self._copy_group_permissions(entity_perms, permission)
else:
self._add_bot_permission(entity_perms, permission)

self._current = None

def get_permissions(self, entity):
"""Get the set of permissions for *entity*.
The union of the permissions for *entity* and the universal (``*``)
permissions is returned. If *entity* is ``None``, only the universal
permissions are returned.
"""
if entity is None:
return set(self.get('*', set()))
else:
return self.get(entity, set()) | self.get('*', set())

def check(self, entity, permission, channel=None):
"""Check if *entity* has *permission*.
If *channel* is present, check for a channel permission, otherwise check
for a bot permission. Compatible wildcard permissions are also checked.
"""
if channel is None:
checks = {permission, '*'}
else:
checks = {(channel, permission), (channel, '*'),
('*', permission), ('*', '*')}

return len(checks & self.get_permissions(entity)) > 0

def _add_channel_permissions(self, entity_perms, permission):
channel, _, permissions = permission.partition(':')

if not (channel == '*' or channel.startswith('#')):
self._error('Invalid channel name "{}", must be * or #channel'
.format(channel))
if permissions == '':
self._error('No permissions specified for channel "{}"'
.format(channel))

for p in permissions.split(','):
entity_perms.add((channel, p))

def _copy_group_permissions(self, entity_perms, group):
if group not in self._groups:
self._error('Permission group "{}" undefined'.format(group))
if group == self._current[0]:
self._error('Recursive group definition')
entity_perms.update(self._groups[group])

def _add_bot_permission(self, entity_perms, permission):
entity_perms.add(permission)

def _error(self, s, *args):
entity, perms = self._current
raise ValueError('{} (in: {} = {})'.format(s, entity, perms), *args)


class Auth(Plugin):
PLUGIN_DEPENDS = ['usertrack']

def setup(self):
super(Auth, self).setup()

self._permissions = PermissionDB()
for entity, permissions in self.config.items():
self._permissions.process(entity, permissions)

for e, p in self._permissions.iteritems():
self.log.debug((e, p))

def check(self, nick, perm, channel=None):
account = self.bot.plugins['usertrack'].get_user(nick)['account']
return self._permissions.check(account, perm, channel)
89 changes: 89 additions & 0 deletions csbot/plugins/usertrack.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
from collections import defaultdict
from copy import deepcopy

from csbot.plugin import Plugin
from csbot.util import nick


class UserDict(defaultdict):
def __missing__(self, key):
user = self.create_user(key)
self[key] = user
return user

@staticmethod
def create_user(nick):
return {
'nick': nick,
'account': None,
'channels': set(),
}

def copy_or_create(self, nick):
if nick in self:
return deepcopy(self[nick])
else:
return self.create_user(nick)


class UserTrack(Plugin):
def setup(self):
super(UserTrack, self).setup()
self._users = UserDict()

@Plugin.hook('core.channel.joined')
def _channel_joined(self, e):
user = self._users[nick(e['user'])]
user['channels'].add(e['channel'])

@Plugin.hook('core.channel.left')
def _channel_left(self, e):
user = self._users[nick(e['user'])]
user['channels'].discard(e['channel'])
# Lost sight of the user, can't reliably track them any more
if len(user['channels']) == 0:
del self._users[nick(e['user'])]

@Plugin.hook('core.channel.names')
def _channel_names(self, e):
for name, prefixes in e['names']:
user = self._users[name]
user['channels'].add(e['channel'])

@Plugin.hook('core.user.identified')
def _user_identified(self, e):
user = self._users[nick(e['user'])]
user['account'] = e['account']

@Plugin.hook('core.user.renamed')
def _user_renamed(self, e):
# Retrieve user record
user = self._users[e['oldnick']]
# Remove old nick entry
del self._users[user['nick']]
# Rename user
user['nick'] = e['newnick']
# Add under new nick
self._users[user['nick']] = user

@Plugin.hook('core.user.quit')
def _user_quit(self, e):
# User is gone, remove record
del self._users[nick(e['user'])]

def get_user(self, nick):
"""Get a copy of the user record for *nick*.
"""
return self._users.copy_or_create(nick)

@Plugin.command('account', help=('account [nick]: show Freenode account for'
' a nick, or for yourself if omitted'))
def account_command(self, e):
nick_ = e['data'] or nick(e['user'])
account = self.get_user(nick_)['account']
if account is None:
e.protocol.msg(e['reply_to'],
u'{} is not authenticated'.format(nick_))
else:
e.protocol.msg(e['reply_to'],
u'{} is authenticated as {}'.format(nick_, account))
39 changes: 39 additions & 0 deletions csbot/test/helpers.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import os
from StringIO import StringIO
from textwrap import dedent

from twisted.trial import unittest
from twisted.test import proto_helpers

from csbot.core import Bot, BotProtocol


class TempEnvVars(object):
Expand All @@ -21,3 +28,35 @@ def __exit__(self, exc_type, exc_value, traceback):
os.environ[k] = self.restore[k]
else:
del os.environ[k]


class BotTestCase(unittest.TestCase):
"""Common functionality for bot test case.
A :class:`unittest.TestCase` with bot and plugin test fixtures. Creates a
bot from :attr:`CONFIG`, binding it to ``self.bot``, and also binding every
plugin in :attr:`PLUGINS` to ``self.plugin``.
"""
CONFIG = ""
PLUGINS = []

def setUp(self):
"""Create bot and plugin bindings."""
# Bot and protocol stuff, suffixed with _ so they can't clash with
# possible/likely plugin names
self.bot_ = Bot(StringIO(dedent(self.CONFIG)))
self.bot_.bot_setup()
self.transport_ = proto_helpers.StringTransport()
self.protocol_ = BotProtocol(self.bot_)
self.protocol_.transport = self.transport_

for p in self.PLUGINS:
setattr(self, p, self.bot_.plugins[p])

def tearDown(self):
"""Lose references to bot and plugins."""
self.bot_ = None
self.transport_ = None
self.protocol_ = None
for p in self.PLUGINS:
setattr(self, p, None)
Loading

0 comments on commit e48b79d

Please sign in to comment.