]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - lib/rbot/botuser.rb
Minor messagemapper optimizations
[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 require 'singleton'\r
11 \r
12 \r
13 module Irc\r
14 \r
15 \r
16   # This module contains the actual Authentication stuff\r
17   #\r
18   module Auth\r
19 \r
20     BotConfig.register BotConfigStringValue.new( 'auth.password',\r
21       :default => 'rbotauth', :wizard => true,\r
22       :desc => 'Password for the bot owner' )\r
23     BotConfig.register BotConfigBooleanValue.new( 'auth.login_by_mask',\r
24       :default => 'true',\r
25       :desc => 'Set false to prevent new botusers from logging in without a password when the user netmask is known')\r
26     BotConfig.register BotConfigBooleanValue.new( 'auth.autologin',\r
27       :default => 'true',\r
28       :desc => 'Set false to prevent new botusers from recognizing IRC users without a need to manually login')\r
29     # BotConfig.register BotConfigIntegerValue.new( 'auth.default_level',\r
30     #   :default => 10, :wizard => true,\r
31     #   :desc => 'The default level for new/unknown users' )\r
32 \r
33     # Generate a random password of length _l_\r
34     #\r
35     def Auth.random_password(l=8)\r
36       pwd = ""\r
37       8.times do\r
38         pwd += (rand(26) + (rand(2) == 0 ? 65 : 97) ).chr\r
39       end\r
40       return pwd\r
41     end\r
42 \r
43 \r
44     # An Irc::Auth::Command defines a command by its "path":\r
45     #\r
46     #   base::command::subcommand::subsubcommand::subsubsubcommand\r
47     #\r
48     class Command\r
49 \r
50       attr_reader :command, :path\r
51 \r
52       # A method that checks if a given _cmd_ is in a form that can be\r
53       # reduced into a canonical command path, and if so, returns it\r
54       #\r
55       def sanitize_command_path(cmd)\r
56         pre = cmd.to_s.downcase.gsub(/^\*?(?:::)?/,"").gsub(/::$/,"")\r
57         return pre if pre.empty?\r
58         return pre if pre =~ /^\S+(::\S+)*$/\r
59         raise TypeError, "#{cmd.inspect} is not a valid command"\r
60       end\r
61 \r
62       # Creates a new Command from a given string; you can then access\r
63       # the command as a symbol with the :command method and the whole\r
64       # path as :path\r
65       #\r
66       #   Command.new("core::auth::save").path => [:"*", :"core", :"core::auth", :"core::auth::save"]\r
67       #\r
68       #   Command.new("core::auth::save").command => :"core::auth::save"\r
69       #\r
70       def initialize(cmd)\r
71         cmdpath = sanitize_command_path(cmd).split('::')\r
72         seq = cmdpath.inject(["*"]) { |list, cmd|\r
73           list << (list.length > 1 ? list.last + "::" : "") + cmd\r
74         }\r
75         @path = seq.map { |k|\r
76           k.to_sym\r
77         }\r
78         @command = path.last\r
79         debug "Created command #{@command.inspect} with path #{@path.join(', ')}"\r
80       end\r
81 \r
82       # Returs self\r
83       def to_irc_auth_command\r
84         self\r
85       end\r
86 \r
87     end\r
88 \r
89   end\r
90 \r
91 end\r
92 \r
93 \r
94 class String\r
95 \r
96   # Returns an Irc::Auth::Comand from the receiver\r
97   def to_irc_auth_command\r
98     Irc::Auth::Command.new(self)\r
99   end\r
100 \r
101 end\r
102 \r
103 \r
104 class Symbol\r
105 \r
106   # Returns an Irc::Auth::Comand from the receiver\r
107   def to_irc_auth_command\r
108     Irc::Auth::Command.new(self)\r
109   end\r
110 \r
111 end\r
112 \r
113 \r
114 module Irc\r
115 \r
116 \r
117   module Auth\r
118 \r
119 \r
120     # This class describes a permission set\r
121     class PermissionSet\r
122 \r
123       attr_reader :perm\r
124       # Create a new (empty) PermissionSet\r
125       #\r
126       def initialize\r
127         @perm = {}\r
128       end\r
129 \r
130       # Inspection simply inspects the internal hash\r
131       def inspect\r
132         @perm.inspect\r
133       end\r
134 \r
135       # Sets the permission for command _cmd_ to _val_,\r
136       #\r
137       def set_permission(str, val)\r
138         cmd = str.to_irc_auth_command\r
139         case val\r
140         when true, false\r
141           @perm[cmd.command] = val\r
142         when nil\r
143           @perm.delete(cmd.command)\r
144         else\r
145           raise TypeError, "#{val.inspect} must be true or false" unless [true,false].include?(val)\r
146         end\r
147       end\r
148 \r
149       # Resets the permission for command _cmd_\r
150       #\r
151       def reset_permission(cmd)\r
152         set_permission(cmd, nil)\r
153       end\r
154 \r
155       # Tells if command _cmd_ is permitted. We do this by returning\r
156       # the value of the deepest Command#path that matches.\r
157       #\r
158       def permit?(str)\r
159         cmd = str.to_irc_auth_command\r
160         allow = nil\r
161         cmd.path.reverse.each { |k|\r
162           if @perm.has_key?(k)\r
163             allow = @perm[k]\r
164             break\r
165           end\r
166         }\r
167         return allow\r
168       end\r
169 \r
170     end\r
171 \r
172 \r
173     # This is the error that gets raised when an invalid password is met\r
174     #\r
175     class InvalidPassword < RuntimeError\r
176     end\r
177 \r
178 \r
179     # This is the basic class for bot users: they have a username, a password,\r
180     # a list of netmasks to match against, and a list of permissions.\r
181     #\r
182     class BotUser\r
183 \r
184       attr_reader :username\r
185       attr_reader :password\r
186       attr_reader :netmasks\r
187       attr_reader :perm\r
188       attr_writer :login_by_mask\r
189       attr_writer :autologin\r
190 \r
191       # Create a new BotUser with given username\r
192       def initialize(username)\r
193         @username = BotUser.sanitize_username(username)\r
194         @password = nil\r
195         @netmasks = NetmaskList.new\r
196         @perm = {}\r
197         reset_login_by_mask\r
198         reset_autologin\r
199       end\r
200 \r
201       # Inspection\r
202       def inspect\r
203         str = "<#{self.class}:#{'0x%08x' % self.object_id}:"\r
204         str << " @username=#{@username.inspect}"\r
205         str << " @netmasks=#{@netmasks.inspect}"\r
206         str << " @perm=#{@perm.inspect}"\r
207         str << " @login_by_mask=#{@login_by_mask}"\r
208         str << " @autologin=#{@autologin}"\r
209         str << ">"\r
210       end\r
211 \r
212       # In strings\r
213       def to_s\r
214         @username\r
215       end\r
216 \r
217       # Convert into a hash\r
218       def to_hash\r
219         {\r
220           :username => @username,\r
221           :password => @password,\r
222           :netmasks => @netmasks,\r
223           :perm => @perm,\r
224           :login_by_mask => @login_by_mask,\r
225           :autologin => @autologin\r
226         }\r
227       end\r
228 \r
229       # Do we allow logging in without providing the password?\r
230       #\r
231       def login_by_mask?\r
232         @login_by_mask\r
233       end\r
234 \r
235       # Reset the login-by-mask option\r
236       #\r
237       def reset_login_by_mask\r
238         @login_by_mask = Auth.authmanager.bot.config['auth.login_by_mask'] unless defined?(@login_by_mask)\r
239       end\r
240 \r
241       # Reset the autologin option\r
242       #\r
243       def reset_autologin\r
244         @autologin = Auth.authmanager.bot.config['auth.autologin'] unless defined?(@autologin)\r
245       end\r
246 \r
247       # Do we allow automatic logging in?\r
248       #\r
249       def autologin?\r
250         @autologin\r
251       end\r
252 \r
253       # Restore from hash\r
254       def from_hash(h)\r
255         @username = h[:username] if h.has_key?(:username)\r
256         @password = h[:password] if h.has_key?(:password)\r
257         @netmasks = h[:netmasks] if h.has_key?(:netmasks)\r
258         @perm = h[:perm] if h.has_key?(:perm)\r
259         @login_by_mask = h[:login_by_mask] if h.has_key?(:login_by_mask)\r
260         @autologin = h[:autologin] if h.has_key?(:autologin)\r
261       end\r
262 \r
263       # This method sets the password if the proposed new password\r
264       # is valid\r
265       def password=(pwd=nil)\r
266         if pwd\r
267           begin\r
268             raise InvalidPassword, "#{pwd} contains invalid characters" if pwd !~ /^[A-Za-z0-9]+$/\r
269             raise InvalidPassword, "#{pwd} too short" if pwd.length < 4\r
270             @password = pwd\r
271           rescue InvalidPassword => e\r
272             raise e\r
273           rescue => e\r
274             raise InvalidPassword, "Exception #{e.inspect} while checking #{pwd}"\r
275           end\r
276         else\r
277           reset_password\r
278         end\r
279       end\r
280 \r
281       # Resets the password by creating a new onw\r
282       def reset_password\r
283         @password = Auth.random_password\r
284       end\r
285 \r
286       # Sets the permission for command _cmd_ to _val_ on channel _chan_\r
287       #\r
288       def set_permission(cmd, val, chan="*")\r
289         k = chan.to_s.to_sym\r
290         @perm[k] = PermissionSet.new unless @perm.has_key?(k)\r
291         @perm[k].set_permission(cmd, val)\r
292       end\r
293 \r
294       # Resets the permission for command _cmd_ on channel _chan_\r
295       #\r
296       def reset_permission(cmd, chan ="*")\r
297         set_permission(cmd, nil, chan)\r
298       end\r
299 \r
300       # Checks if BotUser is allowed to do something on channel _chan_,\r
301       # or on all channels if _chan_ is nil\r
302       #\r
303       def permit?(cmd, chan=nil)\r
304         if chan\r
305           k = chan.to_s.to_sym\r
306         else\r
307           k = :*\r
308         end\r
309         allow = nil\r
310         if @perm.has_key?(k)\r
311           allow = @perm[k].permit?(cmd)\r
312         end\r
313         return allow\r
314       end\r
315 \r
316       # Adds a Netmask\r
317       #\r
318       def add_netmask(mask)\r
319         @netmasks << mask.to_irc_netmask\r
320       end\r
321 \r
322       # Removes a Netmask\r
323       #\r
324       def delete_netmask(mask)\r
325         m = mask.to_irc_netmask\r
326         @netmasks.delete(m)\r
327       end\r
328 \r
329       # Removes all <code>Netmask</code>s\r
330       #\r
331       def reset_netmasks\r
332         @netmasks = NetmaskList.new\r
333       end\r
334 \r
335       # This method checks if BotUser has a Netmask that matches _user_\r
336       #\r
337       def knows?(usr)\r
338         user = usr.to_irc_user\r
339         known = false\r
340         @netmasks.each { |n|\r
341           if user.matches?(n)\r
342             known = true\r
343             break\r
344           end\r
345         }\r
346         return known\r
347       end\r
348 \r
349       # This method gets called when User _user_ wants to log in.\r
350       # It returns true or false depending on whether the password\r
351       # is right. If it is, the Netmask of the user is added to the\r
352       # list of acceptable Netmask unless it's already matched.\r
353       def login(user, password)\r
354         if password == @password or (password.nil? and (@login_by_mask || @autologin) and knows?(user))\r
355           add_netmask(user) unless knows?(user)\r
356           debug "#{user} logged in as #{self.inspect}"\r
357           return true\r
358         else\r
359           return false\r
360         end\r
361       end\r
362 \r
363       # # This method gets called when User _user_ has logged out as this BotUser\r
364       # def logout(user)\r
365       #   delete_netmask(user) if knows?(user)\r
366       # end\r
367 \r
368       # This method sanitizes a username by chomping, downcasing\r
369       # and replacing any nonalphanumeric character with _\r
370       #\r
371       def BotUser.sanitize_username(name)\r
372         candidate = name.to_s.chomp.downcase.gsub(/[^a-z0-9]/,"_")\r
373         raise "sanitized botusername #{candidate} too short" if candidate.length < 3\r
374         return candidate\r
375       end\r
376 \r
377     end\r
378 \r
379 \r
380     # This is the default BotUser: it's used for all users which haven't\r
381     # identified with the bot\r
382     #\r
383     class DefaultBotUserClass < BotUser\r
384 \r
385       private :add_netmask, :delete_netmask\r
386 \r
387       include Singleton\r
388 \r
389       # The default BotUser is named 'everyone'\r
390       #\r
391       def initialize\r
392         reset_login_by_mask\r
393         reset_autologin\r
394         super("everyone")\r
395         @default_perm = PermissionSet.new\r
396       end\r
397 \r
398       # This method returns without changing anything\r
399       #\r
400       def login_by_mask=(val)\r
401         debug "Tried to change the login-by-mask for default bot user, ignoring"\r
402         return @login_by_mask\r
403       end\r
404 \r
405       # The default botuser allows logins by mask\r
406       #\r
407       def reset_login_by_mask\r
408         @login_by_mask = true\r
409       end\r
410 \r
411       # This method returns without changing anything\r
412       #\r
413       def autologin=(val)\r
414         debug "Tried to change the autologin for default bot user, ignoring"\r
415         return\r
416       end\r
417 \r
418       # The default botuser doesn't allow autologin (meaningless)\r
419       #\r
420       def reset_autologin\r
421         @autologin = false\r
422       end\r
423 \r
424       # Sets the default permission for the default user (i.e. the ones\r
425       # set by the BotModule writers) on all channels\r
426       #\r
427       def set_default_permission(cmd, val)\r
428         @default_perm.set_permission(Command.new(cmd), val)\r
429         debug "Default permissions now:\n#{@default_perm.inspect}"\r
430       end\r
431 \r
432       # default knows everybody\r
433       #\r
434       def knows?(user)\r
435         return true if user.to_irc_user\r
436       end\r
437 \r
438       # We always allow logging in as the default user\r
439       def login(user, password)\r
440         return true\r
441       end\r
442 \r
443       # Resets the NetmaskList\r
444       def reset_netmasks\r
445         super\r
446         add_netmask("*!*@*")\r
447       end\r
448 \r
449       # DefaultBotUser will check the default_perm after checking\r
450       # the global ones\r
451       # or on all channels if _chan_ is nil\r
452       #\r
453       def permit?(cmd, chan=nil)\r
454         allow = super(cmd, chan)\r
455         if allow.nil? && chan.nil?\r
456           allow = @default_perm.permit?(cmd)\r
457         end\r
458         return allow\r
459       end\r
460 \r
461     end\r
462 \r
463     # Returns the only instance of DefaultBotUserClass\r
464     #\r
465     def Auth.defaultbotuser\r
466       return DefaultBotUserClass.instance\r
467     end\r
468 \r
469     # This is the BotOwner: he can do everything\r
470     #\r
471     class BotOwnerClass < BotUser\r
472 \r
473       include Singleton\r
474 \r
475       def initialize\r
476         @login_by_mask = false\r
477         @autologin = true\r
478         super("owner")\r
479       end\r
480 \r
481       def permit?(cmd, chan=nil)\r
482         return true\r
483       end\r
484 \r
485     end\r
486 \r
487     # Returns the only instance of BotOwnerClass\r
488     #\r
489     def Auth.botowner\r
490       return BotOwnerClass.instance\r
491     end\r
492 \r
493 \r
494     # This is the AuthManagerClass singleton, used to manage User/BotUser connections and\r
495     # everything\r
496     #\r
497     class AuthManagerClass\r
498 \r
499       include Singleton\r
500 \r
501       attr_reader :everyone\r
502       attr_reader :botowner\r
503       attr_reader :bot\r
504 \r
505       # The instance manages two <code>Hash</code>es: one that maps\r
506       # <code>Irc::User</code>s onto <code>BotUser</code>s, and the other that maps\r
507       # usernames onto <code>BotUser</code>\r
508       def initialize\r
509         @everyone = Auth::defaultbotuser\r
510         @botowner = Auth::botowner\r
511         bot_associate(nil)\r
512       end\r
513 \r
514       def bot_associate(bot)\r
515         raise "Cannot associate with a new bot! Save first" if defined?(@has_changes) && @has_changes\r
516 \r
517         reset_hashes\r
518 \r
519         # Associated bot\r
520         @bot = bot\r
521 \r
522         # This variable is set to true when there have been changes\r
523         # to the botusers list, so that we know when to save\r
524         @has_changes = false\r
525       end\r
526 \r
527       def set_changed\r
528         @has_changes = true\r
529       end\r
530 \r
531       def reset_changed\r
532         @has_changes = false\r
533       end\r
534 \r
535       def changed?\r
536         @has_changes\r
537       end\r
538 \r
539       # resets the hashes\r
540       def reset_hashes\r
541         @botusers = Hash.new\r
542         @allbotusers = Hash.new\r
543         [everyone, botowner].each { |x|\r
544           @allbotusers[x.username.to_sym] = x\r
545         }\r
546       end\r
547 \r
548       def load_array(ary, forced)\r
549         raise "Won't load with unsaved changes" if @has_changes and not forced\r
550         reset_hashes\r
551         ary.each { |x|\r
552           raise TypeError, "#{x} should be a Hash" unless x.kind_of?(Hash)\r
553           u = x[:username]\r
554           unless include?(u)\r
555             create_botuser(u)\r
556           end\r
557           get_botuser(u).from_hash(x)\r
558         }\r
559         @has_changes=false\r
560       end\r
561 \r
562       def save_array\r
563         @allbotusers.values.map { |x|\r
564           x.to_hash\r
565         }\r
566       end\r
567 \r
568       # checks if we know about a certain BotUser username\r
569       def include?(botusername)\r
570         @allbotusers.has_key?(botusername.to_sym)\r
571       end\r
572 \r
573       # Maps <code>Irc::User</code> to BotUser\r
574       def irc_to_botuser(ircuser)\r
575         logged = @botusers[ircuser.to_irc_user]\r
576         return logged if logged\r
577         return autologin(ircuser)\r
578       end\r
579 \r
580       # creates a new BotUser\r
581       def create_botuser(name, password=nil)\r
582         n = BotUser.sanitize_username(name)\r
583         k = n.to_sym\r
584         raise "botuser #{n} exists" if include?(k)\r
585         bu = BotUser.new(n)\r
586         bu.password = password\r
587         @allbotusers[k] = bu\r
588         return bu\r
589       end\r
590 \r
591       # returns the botuser with name _name_\r
592       def get_botuser(name)\r
593         @allbotusers.fetch(BotUser.sanitize_username(name).to_sym)\r
594       end\r
595 \r
596       # Logs Irc::User _user_ in to BotUser _botusername_ with password _pwd_\r
597       #\r
598       # raises an error if _botusername_ is not a known BotUser username\r
599       #\r
600       # It is possible to autologin by Netmask, on request\r
601       #\r
602       def login(user, botusername, pwd=nil)\r
603         ircuser = user.to_irc_user\r
604         n = BotUser.sanitize_username(botusername)\r
605         k = n.to_sym\r
606         raise "No such BotUser #{n}" unless include?(k)\r
607         if @botusers.has_key?(ircuser)\r
608           return true if @botusers[ircuser].username == n\r
609           # TODO\r
610           # @botusers[ircuser].logout(ircuser)\r
611         end\r
612         bu = @allbotusers[k]\r
613         if bu.login(ircuser, pwd)\r
614           @botusers[ircuser] = bu\r
615           return true\r
616         end\r
617         return false\r
618       end\r
619 \r
620       # Tries to auto-login Irc::User _user_ by looking at the known botusers that allow autologin\r
621       # and trying to login without a password\r
622       #\r
623       def autologin(user)\r
624         ircuser = user.to_irc_user\r
625         debug "Trying to autlogin #{ircuser}"\r
626         return @botusers[ircuser] if @botusers.has_key?(ircuser)\r
627         @allbotusers.each { |n, bu|\r
628           debug "Checking with #{n}"\r
629           return bu if bu.autologin? and login(ircuser, n)\r
630         }\r
631         return everyone\r
632       end\r
633 \r
634       # Checks if User _user_ can do _cmd_ on _chan_.\r
635       #\r
636       # Permission are checked in this order, until a true or false\r
637       # is returned:\r
638       # * associated BotUser on _chan_\r
639       # * associated BotUser on all channels\r
640       # * everyone on _chan_\r
641       # * everyone on all channels\r
642       #\r
643       def permit?(user, cmdtxt, channel=nil)\r
644         if user.class <= BotUser\r
645           botuser = user\r
646         else\r
647           botuser = irc_to_botuser(user)\r
648         end\r
649         cmd = cmdtxt.to_irc_auth_command\r
650 \r
651         chan = channel\r
652         case chan\r
653         when User\r
654           chan = "?"\r
655         when Channel\r
656           chan = chan.name\r
657         end\r
658 \r
659         allow = nil\r
660 \r
661         allow = botuser.permit?(cmd, chan) if chan\r
662         return allow unless allow.nil?\r
663         allow = botuser.permit?(cmd)\r
664         return allow unless allow.nil?\r
665 \r
666         unless botuser == everyone\r
667           allow = everyone.permit?(cmd, chan) if chan\r
668           return allow unless allow.nil?\r
669           allow = everyone.permit?(cmd)\r
670           return allow unless allow.nil?\r
671         end\r
672 \r
673         raise "Could not check permission for user #{user.inspect} to run #{cmdtxt.inspect} on #{chan.inspect}"\r
674       end\r
675 \r
676       # Checks if command _cmd_ is allowed to User _user_ on _chan_, optionally\r
677       # telling if the user is authorized\r
678       #\r
679       def allow?(cmdtxt, user, chan=nil)\r
680         if permit?(user, cmdtxt, chan)\r
681           return true\r
682         else\r
683           # cmds = cmdtxt.split('::')\r
684           # @bot.say chan, "you don't have #{cmds.last} (#{cmds.first}) permissions here" if chan\r
685           @bot.say chan, "#{user}, you don't have '#{cmdtxt}' permissions here" if chan\r
686           return false\r
687         end\r
688       end\r
689 \r
690     end\r
691 \r
692     # Returns the only instance of AuthManagerClass\r
693     #\r
694     def Auth.authmanager\r
695       return AuthManagerClass.instance\r
696     end\r
697 \r
698   end\r
699 \r
700 end\r