root / lib / http / auth.py @ 4c61d894
History | View | Annotate | Download (8.9 kB)
1 | be500c29 | Michael Hanselmann | #
|
---|---|---|---|
2 | be500c29 | Michael Hanselmann | #
|
3 | be500c29 | Michael Hanselmann | |
4 | be500c29 | Michael Hanselmann | # Copyright (C) 2007, 2008 Google Inc.
|
5 | be500c29 | Michael Hanselmann | #
|
6 | be500c29 | Michael Hanselmann | # This program is free software; you can redistribute it and/or modify
|
7 | be500c29 | Michael Hanselmann | # it under the terms of the GNU General Public License as published by
|
8 | be500c29 | Michael Hanselmann | # the Free Software Foundation; either version 2 of the License, or
|
9 | be500c29 | Michael Hanselmann | # (at your option) any later version.
|
10 | be500c29 | Michael Hanselmann | #
|
11 | be500c29 | Michael Hanselmann | # This program is distributed in the hope that it will be useful, but
|
12 | be500c29 | Michael Hanselmann | # WITHOUT ANY WARRANTY; without even the implied warranty of
|
13 | be500c29 | Michael Hanselmann | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
14 | be500c29 | Michael Hanselmann | # General Public License for more details.
|
15 | be500c29 | Michael Hanselmann | #
|
16 | be500c29 | Michael Hanselmann | # You should have received a copy of the GNU General Public License
|
17 | be500c29 | Michael Hanselmann | # along with this program; if not, write to the Free Software
|
18 | be500c29 | Michael Hanselmann | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
|
19 | be500c29 | Michael Hanselmann | # 02110-1301, USA.
|
20 | be500c29 | Michael Hanselmann | |
21 | be500c29 | Michael Hanselmann | """HTTP authentication module.
|
22 | be500c29 | Michael Hanselmann |
|
23 | be500c29 | Michael Hanselmann | """
|
24 | be500c29 | Michael Hanselmann | |
25 | be500c29 | Michael Hanselmann | import logging |
26 | be500c29 | Michael Hanselmann | import re |
27 | be500c29 | Michael Hanselmann | import base64 |
28 | be500c29 | Michael Hanselmann | import binascii |
29 | be500c29 | Michael Hanselmann | |
30 | be500c29 | Michael Hanselmann | from ganeti import utils |
31 | be500c29 | Michael Hanselmann | from ganeti import http |
32 | be500c29 | Michael Hanselmann | |
33 | be500c29 | Michael Hanselmann | from cStringIO import StringIO |
34 | be500c29 | Michael Hanselmann | |
35 | bf9bd8dd | Michael Hanselmann | try:
|
36 | bf9bd8dd | Michael Hanselmann | from hashlib import md5 |
37 | bf9bd8dd | Michael Hanselmann | except ImportError: |
38 | bf9bd8dd | Michael Hanselmann | from md5 import new as md5 |
39 | bf9bd8dd | Michael Hanselmann | |
40 | be500c29 | Michael Hanselmann | |
41 | be500c29 | Michael Hanselmann | # Digest types from RFC2617
|
42 | be500c29 | Michael Hanselmann | HTTP_BASIC_AUTH = "Basic"
|
43 | be500c29 | Michael Hanselmann | HTTP_DIGEST_AUTH = "Digest"
|
44 | be500c29 | Michael Hanselmann | |
45 | be500c29 | Michael Hanselmann | # Not exactly as described in RFC2616, section 2.2, but good enough
|
46 | 8a088b79 | Guido Trotter | _NOQUOTE = re.compile(r"^[-_a-z0-9]+$", re.I)
|
47 | be500c29 | Michael Hanselmann | |
48 | be500c29 | Michael Hanselmann | |
49 | be500c29 | Michael Hanselmann | def _FormatAuthHeader(scheme, params): |
50 | be500c29 | Michael Hanselmann | """Formats WWW-Authentication header value as per RFC2617, section 1.2
|
51 | be500c29 | Michael Hanselmann |
|
52 | be500c29 | Michael Hanselmann | @type scheme: str
|
53 | be500c29 | Michael Hanselmann | @param scheme: Authentication scheme
|
54 | be500c29 | Michael Hanselmann | @type params: dict
|
55 | be500c29 | Michael Hanselmann | @param params: Additional parameters
|
56 | be500c29 | Michael Hanselmann | @rtype: str
|
57 | be500c29 | Michael Hanselmann | @return: Formatted header value
|
58 | be500c29 | Michael Hanselmann |
|
59 | be500c29 | Michael Hanselmann | """
|
60 | be500c29 | Michael Hanselmann | buf = StringIO() |
61 | be500c29 | Michael Hanselmann | |
62 | be500c29 | Michael Hanselmann | buf.write(scheme) |
63 | be500c29 | Michael Hanselmann | |
64 | be500c29 | Michael Hanselmann | for name, value in params.iteritems(): |
65 | be500c29 | Michael Hanselmann | buf.write(" ")
|
66 | be500c29 | Michael Hanselmann | buf.write(name) |
67 | be500c29 | Michael Hanselmann | buf.write("=")
|
68 | be500c29 | Michael Hanselmann | if _NOQUOTE.match(value):
|
69 | be500c29 | Michael Hanselmann | buf.write(value) |
70 | be500c29 | Michael Hanselmann | else:
|
71 | be500c29 | Michael Hanselmann | buf.write("\"")
|
72 | be500c29 | Michael Hanselmann | # TODO: Better quoting
|
73 | be500c29 | Michael Hanselmann | buf.write(value.replace("\"", "\\\"")) |
74 | be500c29 | Michael Hanselmann | buf.write("\"")
|
75 | be500c29 | Michael Hanselmann | |
76 | be500c29 | Michael Hanselmann | return buf.getvalue()
|
77 | be500c29 | Michael Hanselmann | |
78 | be500c29 | Michael Hanselmann | |
79 | be500c29 | Michael Hanselmann | class HttpServerRequestAuthentication(object): |
80 | be500c29 | Michael Hanselmann | # Default authentication realm
|
81 | be500c29 | Michael Hanselmann | AUTH_REALM = None
|
82 | be500c29 | Michael Hanselmann | |
83 | bf9bd8dd | Michael Hanselmann | # Schemes for passwords
|
84 | bf9bd8dd | Michael Hanselmann | _CLEARTEXT_SCHEME = "{CLEARTEXT}"
|
85 | bf9bd8dd | Michael Hanselmann | _HA1_SCHEME = "{HA1}"
|
86 | bf9bd8dd | Michael Hanselmann | |
87 | be500c29 | Michael Hanselmann | def GetAuthRealm(self, req): |
88 | be500c29 | Michael Hanselmann | """Returns the authentication realm for a request.
|
89 | be500c29 | Michael Hanselmann |
|
90 | 5bbd3f7f | Michael Hanselmann | MAY be overridden by a subclass, which then can return different realms for
|
91 | be500c29 | Michael Hanselmann | different paths. Returning "None" means no authentication is needed for a
|
92 | be500c29 | Michael Hanselmann | request.
|
93 | be500c29 | Michael Hanselmann |
|
94 | be500c29 | Michael Hanselmann | @type req: L{http.server._HttpServerRequest}
|
95 | be500c29 | Michael Hanselmann | @param req: HTTP request context
|
96 | be500c29 | Michael Hanselmann | @rtype: str or None
|
97 | be500c29 | Michael Hanselmann | @return: Authentication realm
|
98 | be500c29 | Michael Hanselmann |
|
99 | be500c29 | Michael Hanselmann | """
|
100 | 2d54e29c | Iustin Pop | # today we don't have per-request filtering, but we might want to
|
101 | 2d54e29c | Iustin Pop | # add it in the future
|
102 | 2d54e29c | Iustin Pop | # pylint: disable-msg=W0613
|
103 | be500c29 | Michael Hanselmann | return self.AUTH_REALM |
104 | be500c29 | Michael Hanselmann | |
105 | be500c29 | Michael Hanselmann | def PreHandleRequest(self, req): |
106 | be500c29 | Michael Hanselmann | """Called before a request is handled.
|
107 | be500c29 | Michael Hanselmann |
|
108 | be500c29 | Michael Hanselmann | @type req: L{http.server._HttpServerRequest}
|
109 | be500c29 | Michael Hanselmann | @param req: HTTP request context
|
110 | be500c29 | Michael Hanselmann |
|
111 | be500c29 | Michael Hanselmann | """
|
112 | be500c29 | Michael Hanselmann | realm = self.GetAuthRealm(req)
|
113 | be500c29 | Michael Hanselmann | |
114 | 81b59aaf | Iustin Pop | # Authentication not required, and no credentials given?
|
115 | 81b59aaf | Iustin Pop | if realm is None and http.HTTP_AUTHORIZATION not in req.request_headers: |
116 | be500c29 | Michael Hanselmann | return
|
117 | be500c29 | Michael Hanselmann | |
118 | 81b59aaf | Iustin Pop | if realm is None: # in case we don't require auth but someone |
119 | 81b59aaf | Iustin Pop | # passed the crendentials anyway
|
120 | 81b59aaf | Iustin Pop | realm = "Unspecified"
|
121 | 81b59aaf | Iustin Pop | |
122 | be500c29 | Michael Hanselmann | # Check "Authorization" header
|
123 | be500c29 | Michael Hanselmann | if self._CheckAuthorization(req): |
124 | be500c29 | Michael Hanselmann | # User successfully authenticated
|
125 | be500c29 | Michael Hanselmann | return
|
126 | be500c29 | Michael Hanselmann | |
127 | be500c29 | Michael Hanselmann | # Send 401 Unauthorized response
|
128 | be500c29 | Michael Hanselmann | params = { |
129 | be500c29 | Michael Hanselmann | "realm": realm,
|
130 | be500c29 | Michael Hanselmann | } |
131 | be500c29 | Michael Hanselmann | |
132 | be500c29 | Michael Hanselmann | # TODO: Support for Digest authentication (RFC2617, section 3).
|
133 | be500c29 | Michael Hanselmann | # TODO: Support for more than one WWW-Authenticate header with the same
|
134 | be500c29 | Michael Hanselmann | # response (RFC2617, section 4.6).
|
135 | be500c29 | Michael Hanselmann | headers = { |
136 | be500c29 | Michael Hanselmann | http.HTTP_WWW_AUTHENTICATE: _FormatAuthHeader(HTTP_BASIC_AUTH, params), |
137 | be500c29 | Michael Hanselmann | } |
138 | be500c29 | Michael Hanselmann | |
139 | be500c29 | Michael Hanselmann | raise http.HttpUnauthorized(headers=headers)
|
140 | be500c29 | Michael Hanselmann | |
141 | be500c29 | Michael Hanselmann | def _CheckAuthorization(self, req): |
142 | 25e7b43f | Iustin Pop | """Checks 'Authorization' header sent by client.
|
143 | be500c29 | Michael Hanselmann |
|
144 | be500c29 | Michael Hanselmann | @type req: L{http.server._HttpServerRequest}
|
145 | be500c29 | Michael Hanselmann | @param req: HTTP request context
|
146 | be500c29 | Michael Hanselmann | @rtype: bool
|
147 | be500c29 | Michael Hanselmann | @return: Whether user is allowed to execute request
|
148 | be500c29 | Michael Hanselmann |
|
149 | be500c29 | Michael Hanselmann | """
|
150 | be500c29 | Michael Hanselmann | credentials = req.request_headers.get(http.HTTP_AUTHORIZATION, None)
|
151 | be500c29 | Michael Hanselmann | if not credentials: |
152 | be500c29 | Michael Hanselmann | return False |
153 | be500c29 | Michael Hanselmann | |
154 | be500c29 | Michael Hanselmann | # Extract scheme
|
155 | be500c29 | Michael Hanselmann | parts = credentials.strip().split(None, 2) |
156 | be500c29 | Michael Hanselmann | if len(parts) < 1: |
157 | be500c29 | Michael Hanselmann | # Missing scheme
|
158 | be500c29 | Michael Hanselmann | return False |
159 | be500c29 | Michael Hanselmann | |
160 | be500c29 | Michael Hanselmann | # RFC2617, section 1.2: "[...] It uses an extensible, case-insensitive
|
161 | be500c29 | Michael Hanselmann | # token to identify the authentication scheme [...]"
|
162 | be500c29 | Michael Hanselmann | scheme = parts[0].lower()
|
163 | be500c29 | Michael Hanselmann | |
164 | be500c29 | Michael Hanselmann | if scheme == HTTP_BASIC_AUTH.lower():
|
165 | be500c29 | Michael Hanselmann | # Do basic authentication
|
166 | be500c29 | Michael Hanselmann | if len(parts) < 2: |
167 | be500c29 | Michael Hanselmann | raise http.HttpBadRequest(message=("Basic authentication requires" |
168 | be500c29 | Michael Hanselmann | " credentials"))
|
169 | be500c29 | Michael Hanselmann | return self._CheckBasicAuthorization(req, parts[1]) |
170 | be500c29 | Michael Hanselmann | |
171 | be500c29 | Michael Hanselmann | elif scheme == HTTP_DIGEST_AUTH.lower():
|
172 | be500c29 | Michael Hanselmann | # TODO: Implement digest authentication
|
173 | be500c29 | Michael Hanselmann | # RFC2617, section 3.3: "Note that the HTTP server does not actually need
|
174 | be500c29 | Michael Hanselmann | # to know the user's cleartext password. As long as H(A1) is available to
|
175 | be500c29 | Michael Hanselmann | # the server, the validity of an Authorization header may be verified."
|
176 | be500c29 | Michael Hanselmann | pass
|
177 | be500c29 | Michael Hanselmann | |
178 | be500c29 | Michael Hanselmann | # Unsupported authentication scheme
|
179 | be500c29 | Michael Hanselmann | return False |
180 | be500c29 | Michael Hanselmann | |
181 | e09fdcfa | Iustin Pop | def _CheckBasicAuthorization(self, req, in_data): |
182 | be500c29 | Michael Hanselmann | """Checks credentials sent for basic authentication.
|
183 | be500c29 | Michael Hanselmann |
|
184 | be500c29 | Michael Hanselmann | @type req: L{http.server._HttpServerRequest}
|
185 | be500c29 | Michael Hanselmann | @param req: HTTP request context
|
186 | e09fdcfa | Iustin Pop | @type in_data: str
|
187 | e09fdcfa | Iustin Pop | @param in_data: Username and password encoded as Base64
|
188 | be500c29 | Michael Hanselmann | @rtype: bool
|
189 | be500c29 | Michael Hanselmann | @return: Whether user is allowed to execute request
|
190 | be500c29 | Michael Hanselmann |
|
191 | be500c29 | Michael Hanselmann | """
|
192 | be500c29 | Michael Hanselmann | try:
|
193 | e09fdcfa | Iustin Pop | creds = base64.b64decode(in_data.encode('ascii')).decode('ascii') |
194 | be500c29 | Michael Hanselmann | except (TypeError, binascii.Error, UnicodeError): |
195 | be500c29 | Michael Hanselmann | logging.exception("Error when decoding Basic authentication credentials")
|
196 | be500c29 | Michael Hanselmann | return False |
197 | be500c29 | Michael Hanselmann | |
198 | be500c29 | Michael Hanselmann | if ":" not in creds: |
199 | be500c29 | Michael Hanselmann | return False |
200 | be500c29 | Michael Hanselmann | |
201 | be500c29 | Michael Hanselmann | (user, password) = creds.split(":", 1) |
202 | be500c29 | Michael Hanselmann | |
203 | be500c29 | Michael Hanselmann | return self.Authenticate(req, user, password) |
204 | be500c29 | Michael Hanselmann | |
205 | 85414b69 | Iustin Pop | def Authenticate(self, req, user, password): |
206 | be500c29 | Michael Hanselmann | """Checks the password for a user.
|
207 | be500c29 | Michael Hanselmann |
|
208 | 5bbd3f7f | Michael Hanselmann | This function MUST be overridden by a subclass.
|
209 | be500c29 | Michael Hanselmann |
|
210 | be500c29 | Michael Hanselmann | """
|
211 | be500c29 | Michael Hanselmann | raise NotImplementedError() |
212 | e6e94655 | Michael Hanselmann | |
213 | bf9bd8dd | Michael Hanselmann | def VerifyBasicAuthPassword(self, req, username, password, expected): |
214 | bf9bd8dd | Michael Hanselmann | """Checks the password for basic authentication.
|
215 | bf9bd8dd | Michael Hanselmann |
|
216 | 23057d29 | Michael Hanselmann | As long as they don't start with an opening brace ("E{lb}"), old passwords
|
217 | 23057d29 | Michael Hanselmann | are supported. A new scheme uses H(A1) from RFC2617, where H is MD5 and A1
|
218 | bf9bd8dd | Michael Hanselmann | consists of the username, the authentication realm and the actual password.
|
219 | bf9bd8dd | Michael Hanselmann |
|
220 | bf9bd8dd | Michael Hanselmann | @type req: L{http.server._HttpServerRequest}
|
221 | bf9bd8dd | Michael Hanselmann | @param req: HTTP request context
|
222 | bf9bd8dd | Michael Hanselmann | @type username: string
|
223 | bf9bd8dd | Michael Hanselmann | @param username: Username from HTTP headers
|
224 | bf9bd8dd | Michael Hanselmann | @type password: string
|
225 | bf9bd8dd | Michael Hanselmann | @param password: Password from HTTP headers
|
226 | bf9bd8dd | Michael Hanselmann | @type expected: string
|
227 | bf9bd8dd | Michael Hanselmann | @param expected: Expected password with optional scheme prefix (e.g. from
|
228 | bf9bd8dd | Michael Hanselmann | users file)
|
229 | bf9bd8dd | Michael Hanselmann |
|
230 | bf9bd8dd | Michael Hanselmann | """
|
231 | bf9bd8dd | Michael Hanselmann | # Backwards compatibility for old-style passwords without a scheme
|
232 | bf9bd8dd | Michael Hanselmann | if not expected.startswith("{"): |
233 | bf9bd8dd | Michael Hanselmann | expected = self._CLEARTEXT_SCHEME + expected
|
234 | bf9bd8dd | Michael Hanselmann | |
235 | bf9bd8dd | Michael Hanselmann | # Check again, just to be sure
|
236 | bf9bd8dd | Michael Hanselmann | if not expected.startswith("{"): |
237 | bf9bd8dd | Michael Hanselmann | raise AssertionError("Invalid scheme") |
238 | bf9bd8dd | Michael Hanselmann | |
239 | bf9bd8dd | Michael Hanselmann | scheme_end_idx = expected.find("}", 1) |
240 | bf9bd8dd | Michael Hanselmann | |
241 | bf9bd8dd | Michael Hanselmann | # Ensure scheme has a length of at least one character
|
242 | bf9bd8dd | Michael Hanselmann | if scheme_end_idx <= 1: |
243 | bf9bd8dd | Michael Hanselmann | logging.warning("Invalid scheme in password for user '%s'", username)
|
244 | bf9bd8dd | Michael Hanselmann | return False |
245 | bf9bd8dd | Michael Hanselmann | |
246 | bf9bd8dd | Michael Hanselmann | scheme = expected[:scheme_end_idx + 1].upper()
|
247 | bf9bd8dd | Michael Hanselmann | expected_password = expected[scheme_end_idx + 1:]
|
248 | bf9bd8dd | Michael Hanselmann | |
249 | bf9bd8dd | Michael Hanselmann | # Good old plain text password
|
250 | bf9bd8dd | Michael Hanselmann | if scheme == self._CLEARTEXT_SCHEME: |
251 | bf9bd8dd | Michael Hanselmann | return password == expected_password
|
252 | bf9bd8dd | Michael Hanselmann | |
253 | bf9bd8dd | Michael Hanselmann | # H(A1) as described in RFC2617
|
254 | bf9bd8dd | Michael Hanselmann | if scheme == self._HA1_SCHEME: |
255 | bf9bd8dd | Michael Hanselmann | realm = self.GetAuthRealm(req)
|
256 | bf9bd8dd | Michael Hanselmann | if not realm: |
257 | bf9bd8dd | Michael Hanselmann | # There can not be a valid password for this case
|
258 | bf9bd8dd | Michael Hanselmann | return False |
259 | bf9bd8dd | Michael Hanselmann | |
260 | bf9bd8dd | Michael Hanselmann | expha1 = md5() |
261 | bf9bd8dd | Michael Hanselmann | expha1.update("%s:%s:%s" % (username, realm, password))
|
262 | bf9bd8dd | Michael Hanselmann | |
263 | bf9bd8dd | Michael Hanselmann | return (expected_password.lower() == expha1.hexdigest().lower())
|
264 | bf9bd8dd | Michael Hanselmann | |
265 | bf9bd8dd | Michael Hanselmann | logging.warning("Unknown scheme '%s' in password for user '%s'",
|
266 | bf9bd8dd | Michael Hanselmann | scheme, username) |
267 | bf9bd8dd | Michael Hanselmann | |
268 | bf9bd8dd | Michael Hanselmann | return False |
269 | bf9bd8dd | Michael Hanselmann | |
270 | e6e94655 | Michael Hanselmann | |
271 | e6e94655 | Michael Hanselmann | class PasswordFileUser(object): |
272 | e6e94655 | Michael Hanselmann | """Data structure for users from password file.
|
273 | e6e94655 | Michael Hanselmann |
|
274 | e6e94655 | Michael Hanselmann | """
|
275 | e6e94655 | Michael Hanselmann | def __init__(self, name, password, options): |
276 | e6e94655 | Michael Hanselmann | self.name = name
|
277 | e6e94655 | Michael Hanselmann | self.password = password
|
278 | e6e94655 | Michael Hanselmann | self.options = options
|
279 | e6e94655 | Michael Hanselmann | |
280 | e6e94655 | Michael Hanselmann | |
281 | e6e94655 | Michael Hanselmann | def ReadPasswordFile(file_name): |
282 | e6e94655 | Michael Hanselmann | """Reads a password file.
|
283 | e6e94655 | Michael Hanselmann |
|
284 | 25e7b43f | Iustin Pop | Lines in the password file are of the following format::
|
285 | e6e94655 | Michael Hanselmann |
|
286 | 25e7b43f | Iustin Pop | <username> <password> [options]
|
287 | e6e94655 | Michael Hanselmann |
|
288 | e6e94655 | Michael Hanselmann | Fields are separated by whitespace. Username and password are mandatory,
|
289 | 25e7b43f | Iustin Pop | options are optional and separated by comma (','). Empty lines and comments
|
290 | 25e7b43f | Iustin Pop | ('#') are ignored.
|
291 | e6e94655 | Michael Hanselmann |
|
292 | e6e94655 | Michael Hanselmann | @type file_name: str
|
293 | e6e94655 | Michael Hanselmann | @param file_name: Path to password file
|
294 | e6e94655 | Michael Hanselmann | @rtype: dict
|
295 | e6e94655 | Michael Hanselmann | @return: Dictionary containing L{PasswordFileUser} instances
|
296 | e6e94655 | Michael Hanselmann |
|
297 | e6e94655 | Michael Hanselmann | """
|
298 | e6e94655 | Michael Hanselmann | users = {} |
299 | e6e94655 | Michael Hanselmann | |
300 | e6e94655 | Michael Hanselmann | for line in utils.ReadFile(file_name).splitlines(): |
301 | e6e94655 | Michael Hanselmann | line = line.strip() |
302 | e6e94655 | Michael Hanselmann | |
303 | e6e94655 | Michael Hanselmann | # Ignore empty lines and comments
|
304 | e6e94655 | Michael Hanselmann | if not line or line.startswith("#"): |
305 | e6e94655 | Michael Hanselmann | continue
|
306 | e6e94655 | Michael Hanselmann | |
307 | e6e94655 | Michael Hanselmann | parts = line.split(None, 2) |
308 | e6e94655 | Michael Hanselmann | if len(parts) < 2: |
309 | e6e94655 | Michael Hanselmann | # Invalid line
|
310 | e6e94655 | Michael Hanselmann | continue
|
311 | e6e94655 | Michael Hanselmann | |
312 | e6e94655 | Michael Hanselmann | name = parts[0]
|
313 | e6e94655 | Michael Hanselmann | password = parts[1]
|
314 | e6e94655 | Michael Hanselmann | |
315 | e6e94655 | Michael Hanselmann | # Extract options
|
316 | e6e94655 | Michael Hanselmann | options = [] |
317 | e6e94655 | Michael Hanselmann | if len(parts) >= 3: |
318 | e6e94655 | Michael Hanselmann | for part in parts[2].split(","): |
319 | e6e94655 | Michael Hanselmann | options.append(part.strip()) |
320 | e6e94655 | Michael Hanselmann | |
321 | e6e94655 | Michael Hanselmann | users[name] = PasswordFileUser(name, password, options) |
322 | e6e94655 | Michael Hanselmann | |
323 | e6e94655 | Michael Hanselmann | return users |