phpbb: Make sure PhpbbAuth::check_login always returns a tuple.
[matthijs/projects/wipi.git] / conf / auth / phpbb.py
1 # -*- coding: iso-8859-1 -*-
2 """
3     MoinMoin - auth plugin doing a check against MySQL db
4
5     @copyright: 2008 Matthijs Kooijman
6     @license: GNU GPL, see COPYING for details.
7
8     This plugin allows authentication (use accounts and password) and
9     authorization (use groups) against a phpbb Mysql database.
10
11     To use this plugin, you should put it in a place where the config python
12     file can "see" it (somewhere in your pythonpath, or in the same dir as the
13     config file). Then import the setup function and call it.
14
15     For example:
16
17     class FarmConfig:
18         import phpbb
19         (phpbb_auth, phpbb_groups) = phpbb.setup(...)
20         auth = [phpbb_auth]
21         groups = phpbb_groups
22 """
23
24 import MySQLdb
25 # Password encryption module. Python port of the method used by phpbb3.
26 import phpass
27 from MoinMoin import user
28 from MoinMoin.auth import BaseAuth, ContinueLogin
29 from MoinMoin.datastruct.backends import LazyGroupsBackend, LazyGroup
30 from MoinMoin import log
31 logging = log.getLogger(__name__)
32
33
34 def connect(dbhost=None, dbport=None, dbname=None, dbuser=None, dbpass=None, **kwargs):
35     # This code was shamelessly stolen from
36     # django.db.backends.mysql.base.cursor
37     kwargs = {
38         'charset': 'utf8',
39         'use_unicode': False,
40     }
41     if dbuser:
42         kwargs['user'] = dbuser
43     if dbname:
44         kwargs['db'] = dbname
45     if dbpass:
46         kwargs['passwd'] = dbpass
47     if dbhost.startswith('/'):
48         kwargs['unix_socket'] = dbhost
49     elif dbhost:
50         kwargs['host'] = dbhost
51     if dbport:
52         kwargs['port'] = int(dbport)
53
54     # End stolen code
55
56     try:
57         conn = MySQLdb.connect (**kwargs)
58     except:
59         import sys
60         import traceback
61         info = sys.exc_info()
62         logging.error("phpbb: authentication failed due to exception connecting to DB, traceback follows...")
63         logging.error(''.join(traceback.format_exception(*info)))
64         return False
65
66     return conn
67
68
69 class PhpbbGroupsBackend(LazyGroupsBackend):
70     class PhpbbGroup(LazyGroup):
71         """
72         A group from phpbb.
73         """
74         pass
75
76     def __init__(self, request, **kwargs):
77         super(LazyGroupsBackend, self).__init__(request)
78
79         self.request = request
80         self.dbconfig = kwargs
81
82     def __iter__(self):
83         """
84         Return a list of group names.
85         """
86         return self.list_query("SELECT group_name \
87                                 FROM `%sgroups`"
88                                 % self.dbconfig['phpbb_prefix'])
89
90     def __contains__(self, group_name):
91         """
92         Does a group with the given name exist?
93         """
94         return self.single_query("SELECT EXISTS ( \
95                                       SELECT * \
96                                       FROM `%sgroups` \
97                                       WHERE group_name=%%s)" % self.dbconfig['phpbb_prefix'],
98                                  group_name)
99
100     def __getitem__(self, group_name):
101         """
102         Get the group with the given name.
103         """
104         return self.PhpbbGroup(self.request, group_name, self)
105
106     def _iter_group_members(self, group_name):
107         """
108         Get all member names for the given group. This is called by
109         LazyGroup.__iter__.
110         """
111         return self.list_query ("SELECT username \
112                                  FROM `%susers` as u, `%suser_group` as ug, `%sgroups` as g  \
113                                  WHERE u.user_id = ug.user_id AND ug.group_id = g.group_id \
114                                        AND ug.user_pending = 0 AND g.group_name = %%s"
115                                  % (self.dbconfig['phpbb_prefix'], self.dbconfig['phpbb_prefix'], self.dbconfig['phpbb_prefix']),
116                                 group_name)
117
118     def _group_has_member(self, group_name, member):
119         """
120         Does the group with the given name have a member with the given name?
121         This is called by LazyGroup.__contains__.
122         """
123         return self.single_query ("SELECT EXISTS( \
124                                        SELECT * \
125                                        FROM `%susers` as u, `%suser_group` as ug, `%sgroups` as g \
126                                        WHERE u.user_id = ug.user_id AND ug.group_id = g.group_id \
127                                              AND ug.user_pending = 0 \
128                                              AND g.group_name = %%s AND u.username = %%s)"
129                                    % (self.dbconfig['phpbb_prefix'], self.dbconfig['phpbb_prefix'], self.dbconfig['phpbb_prefix']),
130                                   (group_name, member))
131         
132     def groups_with_member(self, member):
133         """
134         Return the group names for all groups that have a member with the
135         given name.
136         """
137         return self.list_query ("SELECT g.group_name \
138                                  FROM `%susers` as u, `%suser_group` as ug, `%sgroups` as g \
139                                  WHERE u.user_id = ug.user_id AND ug.group_id = g.group_id \
140                                        AND ug.user_pending = 0 AND u.username = %%s"
141                                 % (self.dbconfig['phpbb_prefix'], self.dbconfig['phpbb_prefix'], self.dbconfig['phpbb_prefix']),
142                                 member)
143
144     def single_query(self, *args):
145         """
146         Runs an SQL query, that returns single row with a single column.
147         Returns just that single result.
148         """
149         conn = None
150         cursor = None
151         try:
152             conn = connect(**self.dbconfig)
153             cursor = conn.cursor()
154             cursor.execute(*args)
155
156             return cursor.fetchone()[0]
157         finally:
158             if cursor:
159                 cursor.close()
160             if conn:
161                 conn.close()
162         
163     def list_query(self, *args):
164         """
165         Runs an SQL query, that returns any number of single-column rows.
166         Returns the results as a list of single values
167         """
168         conn = None
169         cursor = None
170         try:
171             conn = connect(**self.dbconfig)
172             cursor = conn.cursor()
173             cursor.execute(*args)
174
175             for row in cursor:
176                 yield row[0]
177         finally:
178             if cursor:
179                 cursor.close()
180             if conn:
181                 conn.close()
182         
183 class PhpbbAuth(BaseAuth):
184     logout_possible = True
185     login_inputs    = ['username', 'password']
186     
187     def __init__(self, name='phpbb', hint=None, **kwargs):
188         """
189             Authenticate using credentials from a phpbb database
190
191             The name parameter should be unique among all authentication methods.
192
193             The hint parameter is a snippet of HTML that is displayed below the login form.
194         """
195         self.dbconfig = kwargs
196         self.name    = name
197         self.hint    = hint
198         self.hash    = phpass.PasswordHash()
199
200     def check_login(self, request, username, password):
201         """ Checks the given username password combination. Returns the
202         real username and corresponding emailaddress, or (False, False)
203         if authentication failed. Username checks are case insensitive,
204         so the real username (with the real casing) is returned (since
205         ACL checks _are_ case sensitive).
206         """
207         conn = connect(**self.dbconfig)
208
209         if not conn:
210             return (False, False)
211
212         # Get some data. Note that we interpolate the prefix ourselves, since
213         # letting the mysql library do it only works with values (it adds ''
214         # automatically). Note also that this allows possible SQL injection
215         # through the phpbb_prefix variable, but that should be a trusted
216         # value anyway.
217         # Finally note that by default, the phpbb database specifies a
218         # case insensitive collaction for the username field, so
219         # usernames are checked in case insensitive manner.
220         cursor = conn.cursor ()
221         cursor.execute ("SELECT user_password,user_email,username FROM `%susers` WHERE LOWER(username)=LOWER(%%s)" % self.dbconfig['phpbb_prefix'], username)
222
223         # No data? No login.
224         if (cursor.rowcount == 0):
225             conn.close()
226             return (False, False)
227        
228         # Check password
229         row = cursor.fetchone()
230         conn.close()
231
232         if self.hash.check_password(password, row[0]):
233             return (row[1], row[2])
234         else:
235             return (False, False)
236
237     def login(self, request, user_obj, **kw):
238         """
239         Handle a login. Called by moinmoin.
240         """
241         try:
242             username = kw.get('username')
243             password = kw.get('password')
244
245             logging.debug("phpbb_login: Trying to log in, username=%r " % (username))
246            
247             # simply continue if something else already logged in
248             # successfully
249             if user_obj and user_obj.valid:
250                 return ContinueLogin(user_obj)
251
252             # Deny empty username or passwords
253             if not username or not password:
254                 return ContinueLogin(user_obj)
255
256             (email, real_username) = self.check_login(request, username, password)
257             
258             # Login incorrect
259             if (not email):
260                 logging.debug("phpbb_login: authentication failed for %s" % (username))
261                 return ContinueLogin(user_obj)
262
263             logging.debug("phpbb_login: authenticated %s (email %s, real username %s)" % (username, email, real_username))
264
265             # We use the username from the database (real_username)
266             # here, since the username from the request might have
267             # "wrong" casing (and ACL checks are case sensitive).
268             u = user.User(request, auth_username=real_username, auth_method=self.name, auth_attribs=('name', 'password', 'email'))
269             u.email = email
270             #u.remember_me = 0 # 0 enforces cookie_lifetime config param
271             u.create_or_update(True)
272
273             return ContinueLogin(u)
274         except:
275             import sys
276             import traceback
277             info = sys.exc_info()
278             logging.error("phpbb_login: authentication failed due to unexpected exception, traceback follows...")
279             logging.error(''.join(traceback.format_exception(*info)))
280             return ContinueLogin(user_obj)
281
282     def login_hint(self, request):
283         """ Return a snippet of HTML that is displayed with the login form. """
284         return self.hint
285
286 def setup(**kwargs):
287     """
288     Setup the phpbb backend. Takes the following keyword arguments:
289     dbhost -- The database server host
290     dbport -- The database server portname
291     dbname -- The database name
292     dbuser -- The username to log in
293     dbpass -- The password to log in
294     phpbb_prefix -- The table name prefix used for this phpbb installation
295     name -- The name to use for the auth backend
296     hint -- A hint to show in the login interface (HTML string) 
297
298     This function can be called multiple times to create backends for
299     different phpbb installations
300
301     Returns a tuple (auth, groups) containing an (instantiated) auth backend
302     and a groups backend (constructor). These can be put directly into the
303     "auth" (as part of the list) and "groups" (directly) config directives.
304
305     e.g.,
306     
307     class FarmConfig:
308         (phpbb_auth, phpbb_groups) = phpbb.setup(...)
309         auth = [phpbb_auth]
310         groups = phpbb_groups
311
312     (actual configuration parameters to setup() are omitted in this example)
313     """
314     
315     # Create a "constructor" to create a phpbb_groups instance, while
316     # passing ourselves to it.
317     groups = lambda config, request: PhpbbGroupsBackend(request, **kwargs)
318     # Create an instantiated auth backend.
319     auth = PhpbbAuth(**kwargs)
320
321     return (auth, groups)
322     
323 # vim: set sw=4 expandtab sts=4:vim