]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - lib/rbot/botuser.rb
Modularized core now functional. Still a lot to do and auth missing, but the bot...
[user/henk/code/ruby/rbot.git] / lib / rbot / botuser.rb
1 #-- vim:sw=2:et\r
2 #++\r
3 # :title: User management\r
4 #\r
5 # rbot user management\r
6 # Author:: Giuseppe Bilotta (giuseppe.bilotta@gmail.com)\r
7 # Copyright:: Copyright (c) 2006 Giuseppe Bilotta\r
8 # License:: GPLv2\r
9 \r
10 #--\r
11 #####\r
12 ####\r
13 ### Discussion on IRC on how to implement it\r
14 ##\r
15 #\r
16 # <tango_>      a. do we want user groups together with users?\r
17 # <markey>      hmm\r
18 # <markey>      let me think about it\r
19 # <markey>      generally I would say: as simple as possible while keeping it as flexible as need be\r
20 # <tango_>      I think we can put user groups in place afterwards if we build the structure right\r
21 # <markey>      prolly, yes\r
22 # <tango_>      so\r
23 # <tango_>      each plugin registers a name\r
24 # <tango_>      so rather than auth level we have +name -name\r
25 # <markey>      yes\r
26 # <markey>      much better\r
27 # <tango_>      the default is +name for every plugin, except when the plugin tells otherwise\r
28 # <markey>      although.. \r
29 # <markey>      if I only want to allow you access to one plugin\r
30 # <markey>      I have lots of typing to do\r
31 # <tango_>      nope\r
32 # <tango_>      we allow things like -*\r
33 # <markey>      ok\r
34 # <tango_>      and + has precedence\r
35 # <tango_>      hm no, not good either\r
36 # <tango_>      because we want bot -* +onething and +* -onething to work\r
37 # <markey>      but then: one plugin currently can have several levels, no?\r
38 # <tango_>      of course\r
39 # <markey>      commandedit, commanddel, commandfoo\r
40 # <tango_>      name.command ?\r
41 # <markey>      yep\r
42 # <tango_>      (then you can't have dots in commands\r
43 # <tango_>      maybe name:command\r
44 # <markey>      or name::comand\r
45 # <markey>      like a namespace\r
46 # <tango_>      ehehehe yeah I like it :)\r
47 # <tango_>      tel\r
48 # <tango_>      brb\r
49 # <markey>      usermod setcaps eean -*\r
50 # <markey>      usermod setcaps eean +quiz::edit\r
51 # <markey>      great\r
52 # <markey>      or even\r
53 # <markey>      auth eean -*, +quiz::edit\r
54 # <markey>      awesome\r
55 # <markey>      auth eean -*, +quiz::edit, +command, -command::del\r
56 # <tango_>      yes\r
57 # <markey>      you know, the default should be -*\r
58 # <markey>      because\r
59 # <markey>      in the time between adding the user and changing auth\r
60 # <markey>      it's insecure\r
61 # <markey>      user could do havoc\r
62 # <markey>      useradd eean, then eean does "~quit", before I change auth\r
63 # <tango_>      nope\r
64 # <markey>      perhaps we should allow combining useradd with auth\r
65 # <tango_>      the default should be +* -important stuff\r
66 # <markey>      ok\r
67 # <tango_>      how to specify channel stuff?\r
68 # <markey>      for one, when you issue the command on the channel itself\r
69 # <markey>      then it's channel relative\r
70 # <markey>      perhaps\r
71 # <markey>      or\r
72 # <tango_>      yes but I was thinking more about the syntax\r
73 # <markey>      auth eean #rbot -quiz\r
74 # <tango_>      hm\r
75 # <markey>      or maybe: treat channels like users: auth #rbot -quiz\r
76 # <markey>      would shut up quiz in #rbot\r
77 # <markey>      hm\r
78 # <markey>      heh\r
79 # <tango_>      auth * #rbot -quiz\r
80 # <markey>      not sure I'm making sense here ;)\r
81 # <tango_>      I think syntax should be auth [usermask] [channelmask] [modes]\r
82 # <markey>      yes\r
83 # <markey>      modes separated by comma?\r
84 # <tango_>      where channelmask is implied to be *\r
85 # <tango_>      no we can have it spacesplit\r
86 # <markey>      great\r
87 # <markey>      ok\r
88 # <tango_>      modes are detected by +-\r
89 # <tango_>      so you can do something like auth markey #rbot -quiz #amarok -chuck\r
90 # <markey>      also I like "auth" a lot more than "usermod foo"\r
91 # <markey>      yep\r
92 # <tango_>      I don't understand why the 'mod'\r
93 # <tango_>      we could have all auth commands start with use\r
94 # <tango_>      user\r
95 # <tango_>      user add\r
96 # <tango_>      user list\r
97 # <tango_>      user del\r
98 # <markey>      yes\r
99 # <tango_>      user auth\r
100 # <tango_>      hm\r
101 # <tango_>      and maybe auth as a synonym for user auth\r
102 # <markey>      this is also uncomfortable: usermod wants the full user mask\r
103 # <markey>      you have to copy/paste it\r
104 # <tango_>      no\r
105 # <tango_>      can't you use *?\r
106 # <markey>      sorry not sure\r
107 # <markey>      but this shows, it's not inuitive\r
108 # <markey>      I've read the docs\r
109 # <markey>      but didn't know how to use it really\r
110 # <tango_>      markey!*@*\r
111 # <markey>      that's not very intuitive\r
112 # <tango_>      we could use nick as a synonym for nick!*@* if it's too much for you :D\r
113 # <markey>      usermod markey foo should suffice\r
114 # <markey>      rememember: you're a hacker. when rbot gets many new users, they will often be noobs\r
115 # <markey>      gotta make things simple to use\r
116 # <tango_>      but the hostmask is only needed for the user creation\r
117 # <markey>      really? then forget what I said, sorry\r
118 # <tango_>      I think so\r
119 # <tango_>      ,help auth\r
120 # <testbot>     Auth module (User authentication) topics: setlevel, useradd, userdel, usermod, auth, levels, users, whoami, identify\r
121 # <tango_>      ,help usermod\r
122 # <testbot>     no help for topic usermod\r
123 # <tango_>      ,help auth usermod\r
124 # <testbot>     usermod <username> <item> <value> => Modify <username>s settings. Valid <item>s are: hostmask, (+|-)hostmask, password, level (private addressing only)\r
125 # <tango_>      see? it's username, not nick :D\r
126 # <markey>      btw, help usermod should also work\r
127 # <tango_>      ,help auth useradd\r
128 # <testbot>     useradd <username> => Add user <mask>, you still need to set him up correctly (private addressing only)\r
129 # <markey>      instead of help auth usermode\r
130 # <markey>      when it's not ambiguous\r
131 # <tango_>      and the help for useradd is wrong\r
132 # <markey>      for the website, we could make a logo contest :) the current logo looks like giblet made it in 5 minutes ;)\r
133 # <markey>      ah well, for 1.0 maybe\r
134 # <tango_>      so a user on rbot is given by\r
135 # <tango_>      username, password, hostmasks, permissions\r
136 # <markey>      yup\r
137 # <tango_>      the default permission is +* -importantstuff\r
138 # <markey>      how defines importantstuff?\r
139 # <markey>      you mean like core and auth?\r
140 # <tango_>      yes\r
141 # <markey>      ok\r
142 # <tango_>      but we can decide about this :)\r
143 # <markey>      some plugins are dangerous by default\r
144 # <markey>      like command plugin\r
145 # <markey>      you can do all sorts of nasty shit with it\r
146 # <tango_>      then command plugin will do something like: command.defaultperm("-command")\r
147 # <markey>      yes, good point\r
148 # <tango_>      this is then added to the default permissions (user * channel *)\r
149 # <tango_>      when checking for auth, we go like this:\r
150 # <tango_>      hm\r
151 # <tango_>      check user * channel *\r
152 # <tango_>      then user name channel *\r
153 # <tango_>      then user * channel name\r
154 # <tango_>      then user name channel name\r
155 # <tango_>      for each of these combinations we match against * first, then against command, and then against command::subcommand\r
156 # <markey>      yup\r
157 # <tango_>      setting or resetting it depending on wether it's + or -\r
158 # <tango_>      the final result gives us the permission\r
159 # <tango_>      implementation detail\r
160 # <tango_>      username and passwords are strings\r
161 # <markey>      (I might rename the command plugin, the name is somewhat confusing)\r
162 # <tango_>      yeah\r
163 # <tango_>      hostmasks are hostmasks\r
164 # <markey>      also I'm pondering to restrict it more: disallow access to @bot\r
165 # <tango_>      permissions are in the form [ [channel, {command => bool, ...}] ...]\r
166 #++\r
167 \r
168 require 'singleton'\r
169 \r
170 module Irc\r
171 \r
172   # This method raises a TypeError if _user_ is not of class User\r
173   #\r
174   def Irc.error_if_not_user(user)\r
175     raise TypeError, "#{user.inspect} must be of type Irc::User and not #{user.class}" unless user.class <= User\r
176   end\r
177 \r
178   # This method raises a TypeError if _chan_ is not of class Chan\r
179   #\r
180   def Irc.error_if_not_channel(chan)\r
181     raise TypeError, "#{chan.inspect} must be of type Irc::User and not #{chan.class}" unless chan.class <= Channel\r
182   end\r
183 \r
184 \r
185   # This module contains the actual Authentication stuff\r
186   #\r
187   module Auth\r
188 \r
189     # Generate a random password of length _l_\r
190     #\r
191     def random_password(l=8)\r
192       pwd = ""\r
193       8.times do\r
194         pwd += (rand(26) + (rand(2) == 0 ? 65 : 97) ).chr\r
195       end\r
196       return pwd\r
197     end\r
198 \r
199 \r
200     # An Irc::Auth::Command defines a command by its "path":\r
201     #\r
202     #   base::command::subcommand::subsubcommand::subsubsubcommand\r
203     #\r
204     class Command\r
205 \r
206       attr_reader :command, :path\r
207 \r
208       # A method that checks if a given _cmd_ is in a form that can be\r
209       # reduced into a canonical command path, and if so, returns it\r
210       #\r
211       def sanitize_command_path(cmd)\r
212         pre = cmd.to_s.downcase.gsub(/^\*?(?:::)?/,"").gsub(/::$/,"")\r
213         return pre if pre.empty?\r
214         return pre if pre =~ /^\S+(::\S+)*$/\r
215         raise TypeError, "#{cmd.inspect} is not a valid command"\r
216       end\r
217 \r
218       # Creates a new Command from a given string; you can then access\r
219       # the command as a symbol with the :command method and the whole\r
220       # path as :path\r
221       #\r
222       #   Command.new("core::auth::save").path => [:"*", :"core", :"core::auth", :"core::auth::save"]\r
223       #\r
224       #   Command.new("core::auth::save").command => :"core::auth::save"\r
225       #\r
226       def initialize(cmd)\r
227         cmdpath = sanitize_command_path(cmd).split('::')\r
228         seq = cmdpath.inject(["*"]) { |list, cmd|\r
229           list << (list.length > 1 ? list.last + "::" : "") + cmd\r
230         }\r
231         @path = seq.map { |k|\r
232           k.to_sym\r
233         }\r
234         @command = path.last\r
235         debug "Created command #{@command.inspect} with path #{@path.join(', ')}"\r
236       end\r
237     end\r
238 \r
239     # This method raises a TypeError if _user_ is not of class User\r
240     #\r
241     def Irc.error_if_not_command(cmd)\r
242       raise TypeError, "#{cmd.inspect} must be of type Irc::Auth::Command and not #{cmd.class}" unless cmd.class <= Command\r
243     end\r
244 \r
245 \r
246     # This class describes a permission set\r
247     class PermissionSet\r
248 \r
249       # Create a new (empty) PermissionSet\r
250       #\r
251       def initialize\r
252         @perm = {}\r
253       end\r
254 \r
255       # Sets the permission for command _cmd_ to _val_,\r
256       # creating intermediate permissions if needed.\r
257       #\r
258       def set_permission(cmd, val)\r
259         raise TypeError, "#{val.inspect} must be true or false" unless [true,false].include?(val)\r
260         Irc::error_if_not_command(cmd)\r
261         @perm[cmd.command] = val\r
262       end\r
263 \r
264       # Tells if command _cmd_ is permitted. We do this by returning\r
265       # the value of the deepest Command#path that matches.\r
266       #\r
267       def permit?(cmd)\r
268         Irc::error_if_not_command(cmd)\r
269         allow = nil\r
270         cmd.path.reverse.each { |k|\r
271           if @perm.has_key?(k)\r
272             allow = @perm[k]\r
273             break\r
274           end\r
275         }\r
276         return allow\r
277       end\r
278     end\r
279 \r
280 \r
281     # This is the basic class for bot users: they have a username, a password, a\r
282     # list of netmasks to match against, and a list of permissions.\r
283     #\r
284     class BotUser\r
285 \r
286       attr_reader :username\r
287       attr_reader :password\r
288       attr_reader :netmasks\r
289 \r
290       # Create a new BotUser with given username\r
291       def initialize(username)\r
292         @username = BotUser.sanitize_username(username)\r
293         @password = nil\r
294         @netmasks = NetmaskList.new\r
295         @perm = {}\r
296       end\r
297 \r
298       # Resets the password by creating a new onw\r
299       def reset_password\r
300         @password = random_password\r
301       end\r
302 \r
303       # Sets the permission for command _cmd_ to _val_ on channel _chan_\r
304       #\r
305       def set_permission(cmd, val, chan="*")\r
306         k = chan.to_s.to_sym\r
307         @perm[k] = PermissionSet.new unless @perm.has_key?(k)\r
308         case cmd\r
309         when String\r
310           @perm[k].set_permission(Command.new(cmd), val)\r
311         else\r
312           @perm[k].set_permission(cmd, val)\r
313         end\r
314       end\r
315 \r
316       # Checks if BotUser is allowed to do something on channel _chan_,\r
317       # or on all channels if _chan_ is nil\r
318       #\r
319       def permit?(cmd, chan=nil)\r
320         if chan\r
321           k = chan.to_s.to_sym\r
322         else\r
323           k = :*\r
324         end\r
325         allow = nil\r
326         if @perm.has_key?(k)\r
327           allow = @perm[k].permit?(cmd)\r
328         end\r
329         return allow\r
330       end\r
331 \r
332       # Adds a Netmask\r
333       #\r
334       def add_netmask(mask)\r
335         case mask\r
336         when Netmask\r
337           @netmasks << mask\r
338         else\r
339           @netmasks << Netmask(mask)\r
340         end\r
341       end\r
342 \r
343       # Removes a Netmask\r
344       #\r
345       def delete_netmask(mask)\r
346         case mask\r
347         when Netmask\r
348           m = mask\r
349         else\r
350           m << Netmask(mask)\r
351         end\r
352         @netmasks.delete(m)\r
353       end\r
354 \r
355       # Removes all <code>Netmask</code>s\r
356       def reset_netmask_list\r
357         @netmasks = NetmaskList.new\r
358       end\r
359 \r
360       # This method checks if BotUser has a Netmask that matches _user_\r
361       def knows?(user)\r
362         Irc::error_if_not_user(user)\r
363         known = false\r
364         @netmasks.each { |n|\r
365           if user.matches?(n)\r
366             known = true\r
367             break\r
368           end\r
369         }\r
370         return known\r
371       end\r
372 \r
373       # This method gets called when User _user_ wants to log in.\r
374       # It returns true or false depending on whether the password\r
375       # is right. If it is, the Netmask of the user is added to the\r
376       # list of acceptable Netmask unless it's already matched.\r
377       def login(user, password)\r
378         if password == @password\r
379           add_netmask(user) unless knows?(user)\r
380           return true\r
381         else\r
382           return false\r
383         end\r
384       end\r
385 \r
386       # # This method gets called when User _user_ has logged out as this BotUser\r
387       # def logout(user)\r
388       #   delete_netmask(user) if knows?(user)\r
389       # end\r
390 \r
391       # This method sanitizes a username by chomping, downcasing\r
392       # and replacing any nonalphanumeric character with _\r
393       #\r
394       def BotUser.sanitize_username(name)\r
395         return name.to_s.chomp.downcase.gsub(/[^a-z0-9]/,"_")\r
396       end\r
397 \r
398       # This method sets the password if the proposed new password\r
399       # is valid\r
400       def password=(pwd=nil)\r
401         if pwd\r
402           begin\r
403             raise InvalidPassword, "#{pwd} contains invalid characters" if pwd !~ /^[A-Za-z0-9]+$/\r
404             raise InvalidPassword, "#{pwd} too short" if pwd.length < 4\r
405             @password = pwd\r
406           rescue InvalidPassword => e\r
407             raise e\r
408           rescue => e\r
409             raise InvalidPassword, "Exception #{e.inspect} while checking #{pwd}"\r
410           end\r
411         else\r
412           reset_password\r
413         end\r
414       end\r
415     end\r
416 \r
417 \r
418     # This is the anonymous BotUser: it's used for all users which haven't\r
419     # identified with the bot\r
420     #\r
421     class AnonBotUserClass < BotUser\r
422       include Singleton\r
423       def initialize\r
424         super("anonymous")\r
425       end\r
426       private :login, :add_netmask, :delete_netmask\r
427 \r
428       # Anon knows everybody\r
429       def knows?(user)\r
430         Irc::error_if_not_user(user)\r
431         return true\r
432       end\r
433 \r
434       # Resets the NetmaskList\r
435       def reset_netmask_list\r
436         super\r
437         add_netmask("*!*@*")\r
438       end\r
439     end\r
440 \r
441     # Returns the only instance of AnonBotUserClass\r
442     #\r
443     def Auth.anonbotuser\r
444       return AnonBotUserClass.instance\r
445     end\r
446 \r
447     # This is the BotOwner: he can do everything\r
448     #\r
449     class BotOwnerClass < BotUser\r
450       include Singleton\r
451       def initialize\r
452         super("owner")\r
453       end\r
454 \r
455       def permit?(cmd, chan=nil)\r
456         return true\r
457       end\r
458     end\r
459 \r
460     # Returns the only instance of BotOwnerClass\r
461     #\r
462     def Auth.botowner\r
463       return BotOwnerClass.instance\r
464     end\r
465 \r
466 \r
467     # This is the AuthManagerClass singleton, used to manage User/BotUser connections and\r
468     # everything\r
469     #\r
470     class AuthManagerClass\r
471       include Singleton\r
472 \r
473       # The instance manages two <code>Hash</code>es: one that maps\r
474       # <code>Irc::User</code>s onto <code>BotUser</code>s, and the other that maps\r
475       # usernames onto <code>BotUser</code>\r
476       def initialize\r
477         bot_associate(nil)\r
478       end\r
479 \r
480       def bot_associate(bot)\r
481         raise "Cannot associate with a new bot! Save first" if defined?(@has_changes) && @has_changes\r
482 \r
483         reset_hashes\r
484 \r
485         # Associated bot\r
486         @bot = bot\r
487 \r
488         # This variable is set to true when there have been changes\r
489         # to the botusers list, so that we know when to save\r
490         @has_changes = false\r
491       end\r
492 \r
493       # resets the hashes\r
494       def reset_hashes\r
495         @botusers = Hash.new\r
496         @allbotusers = Hash.new\r
497         [Auth::anonbotuser, Auth::botowner].each { |x| @allbotusers[x.username.to_sym] = x }\r
498       end\r
499 \r
500       # load botlist from userfile\r
501       def load_merge(filename=nil)\r
502         # TODO\r
503         raise NotImplementedError\r
504         @has_changes = true\r
505       end\r
506 \r
507       def load(filename=nil)\r
508         reset_hashes\r
509         load_merge(filename)\r
510       end\r
511 \r
512       # save botlist to userfile\r
513       def save(filename=nil)\r
514         return unless @has_changes\r
515         # TODO\r
516         raise NotImplementedError\r
517       end\r
518 \r
519       # checks if we know about a certain BotUser username\r
520       def include?(botusername)\r
521         @allbotusers.has_key?(botusername.to_sym)\r
522       end\r
523 \r
524       # Maps <code>Irc::User</code> to BotUser\r
525       def irc_to_botuser(ircuser)\r
526         Irc::error_if_not_user(ircuser)\r
527         return @botusers[ircuser] || Auth::anonbotuser\r
528       end\r
529 \r
530       # creates a new BotUser\r
531       def create_botuser(name, password=nil)\r
532         n = BotUser.sanitize_username(name)\r
533         k = n.to_sym\r
534         raise "BotUser #{n} exists" if include?(k)\r
535         bu = BotUser.new(n)\r
536         bu.password = password\r
537         @allbotusers[k] = bu\r
538       end\r
539 \r
540       # Logs Irc::User _ircuser_ in to BotUser _botusername_ with password _pwd_\r
541       #\r
542       # raises an error if _botusername_ is not a known BotUser username\r
543       #\r
544       # It is possible to autologin by Netmask, on request\r
545       #\r
546       def login(ircuser, botusername, pwd, bymask = false)\r
547         Irc::error_if_not_user(ircuser)\r
548         n = BotUser.sanitize_username(name)\r
549         k = n.to_sym\r
550         raise "No such BotUser #{n}" unless include?(k)\r
551         if @botusers.has_key?(ircuser)\r
552           # TODO\r
553           # @botusers[ircuser].logout(ircuser)\r
554         end\r
555         bu = @allbotusers[k]\r
556         if bymask && bu.knows?(user)\r
557           @botusers[ircuser] = bu\r
558           return true\r
559         elsif bu.login(ircuser, pwd)\r
560           @botusers[ircuser] = bu\r
561           return true\r
562         end\r
563         return false\r
564       end\r
565 \r
566       # Checks if User _user_ can do _cmd_ on _chan_.\r
567       #\r
568       # Permission are checked in this order, until a true or false\r
569       # is returned:\r
570       # * associated BotUser on _chan_\r
571       # * associated BotUser on all channels\r
572       # * anonbotuser on _chan_\r
573       # * anonbotuser on all channels\r
574       #\r
575       def permit?(user, cmdtxt, chan=nil)\r
576         botuser = irc_to_botuser(user)\r
577         cmd = Command.new(cmdtxt)\r
578 \r
579         case chan\r
580         when User\r
581           chan = "?"\r
582         when Channel\r
583           chan = chan.name\r
584         end\r
585 \r
586         allow = nil\r
587 \r
588         allow = botuser.permit?(cmd, chan) if chan\r
589         return allow unless allow.nil?\r
590         allow = botuser.permit?(cmd)\r
591         return allow unless allow.nil?\r
592 \r
593         unless botuser == Auth::anonbotuser\r
594           allow = Auth::anonbotuser.permit?(cmd, chan) if chan\r
595           return allow unless allow.nil?\r
596           allow = Auth::anonbotuser.permit?(cmd)\r
597           return allow unless allow.nil?\r
598         end\r
599 \r
600         raise "Could not check permission for user #{user.inspect} to run #{cmdtxt.inspect} on #{chan.inspect}"\r
601       end\r
602 \r
603       # Checks if command _cmd_ is allowed to User _user_ on _chan_\r
604       def allow?(cmdtxt, user, chan=nil)\r
605         permit?(user, cmdtxt, chan)\r
606       end\r
607     end\r
608 \r
609     # Returns the only instance of AuthManagerClass\r
610     #\r
611     def Auth.authmanager\r
612       return AuthManagerClass.instance\r
613     end\r
614   end\r
615 end\r