ibt2.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583
  1. #!/usr/bin/env python
  2. """I'll Be There, 2 (ibt2) - an oversimplified attendees registration system.
  3. Copyright 2016-2017 Davide Alberani <da@erlug.linux.it>
  4. RaspiBO <info@raspibo.org>
  5. Licensed under the Apache License, Version 2.0 (the "License");
  6. you may not use this file except in compliance with the License.
  7. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
  8. Unless required by applicable law or agreed to in writing, software
  9. distributed under the License is distributed on an "AS IS" BASIS,
  10. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  11. See the License for the specific language governing permissions and
  12. limitations under the License.
  13. """
  14. import os
  15. import re
  16. import string
  17. import logging
  18. import datetime
  19. from operator import itemgetter
  20. import itertools
  21. import tornado.httpserver
  22. import tornado.ioloop
  23. import tornado.options
  24. from tornado.options import define, options
  25. import tornado.web
  26. from tornado import gen, escape
  27. import utils
  28. import monco
  29. API_VERSION = '1.0'
  30. class BaseException(Exception):
  31. """Base class for ibt2 custom exceptions.
  32. :param message: text message
  33. :type message: str
  34. :param status: numeric http status code
  35. :type status: int"""
  36. def __init__(self, message, status=400):
  37. super(BaseException, self).__init__(message)
  38. self.message = message
  39. self.status = status
  40. class InputException(BaseException):
  41. """Exception raised by errors in input handling."""
  42. pass
  43. class BaseHandler(tornado.web.RequestHandler):
  44. """Base class for request handlers."""
  45. # Cache currently connected users.
  46. _users_cache = {}
  47. # A property to access the first value of each argument.
  48. arguments = property(lambda self: dict([(k, v[0])
  49. for k, v in self.request.arguments.iteritems()]))
  50. _bool_convert = {
  51. '0': False,
  52. 'n': False,
  53. 'f': False,
  54. 'no': False,
  55. 'off': False,
  56. 'false': False,
  57. '1': True,
  58. 'y': True,
  59. 't': True,
  60. 'on': True,
  61. 'yes': True,
  62. 'true': True
  63. }
  64. _re_split_salt = re.compile(r'\$(?P<salt>.+)\$(?P<hash>.+)')
  65. def write_error(self, status_code, **kwargs):
  66. """Default error handler."""
  67. if isinstance(kwargs.get('exc_info', (None, None))[1], BaseException):
  68. exc = kwargs['exc_info'][1]
  69. status_code = exc.status
  70. message = exc.message
  71. else:
  72. message = 'internal error'
  73. self.build_error(message, status=status_code)
  74. def is_api(self):
  75. """Return True if the path is from an API call."""
  76. return self.request.path.startswith('/v%s' % API_VERSION)
  77. def tobool(self, obj):
  78. """Convert some textual values to boolean."""
  79. if isinstance(obj, (list, tuple)):
  80. obj = obj[0]
  81. if isinstance(obj, (str, unicode)):
  82. obj = obj.lower()
  83. return self._bool_convert.get(obj, obj)
  84. def arguments_tobool(self):
  85. """Return a dictionary of arguments, converted to booleans where possible."""
  86. return dict([(k, self.tobool(v)) for k, v in self.arguments.iteritems()])
  87. def initialize(self, **kwargs):
  88. """Add every passed (key, value) as attributes of the instance."""
  89. for key, value in kwargs.iteritems():
  90. setattr(self, key, value)
  91. @property
  92. def current_user(self):
  93. """Retrieve current user name from the secure cookie."""
  94. return self.get_secure_cookie("user")
  95. @property
  96. def current_user_info(self):
  97. """Information about the current user."""
  98. current_user = self.current_user
  99. if current_user in self._users_cache:
  100. return self._users_cache[current_user]
  101. user_info = {}
  102. if current_user:
  103. user_info['username'] = current_user
  104. res = self.db.query('users', {'username': current_user})
  105. if res:
  106. user = res[0]
  107. user_info = user
  108. self._users_cache[current_user] = user_info
  109. return user_info
  110. def user_authorized(self, username, password):
  111. """Check if a combination of username/password is valid.
  112. :param username: username or email
  113. :type username: str
  114. :param password: password
  115. :type password: str
  116. :returns: tuple like (bool_user_is_authorized, dict_user_info)
  117. :rtype: dict"""
  118. query = [{'username': username}, {'email': username}]
  119. res = self.db.query('users', query)
  120. if not res:
  121. return (False, {})
  122. user = res[0]
  123. db_password = user.get('password') or ''
  124. if not db_password:
  125. return (False, {})
  126. match = self._re_split_salt.match(db_password)
  127. if not match:
  128. return (False, {})
  129. salt = match.group('salt')
  130. if utils.hash_password(password, salt=salt) == db_password:
  131. return (True, user)
  132. return (False, {})
  133. def build_error(self, message='', status=400):
  134. """Build and write an error message.
  135. :param message: textual message
  136. :type message: str
  137. :param status: HTTP status code
  138. :type status: int
  139. """
  140. self.set_status(status)
  141. self.write({'error': True, 'message': message})
  142. def logout(self):
  143. """Remove the secure cookie used fro authentication."""
  144. if self.current_user in self._users_cache:
  145. del self._users_cache[self.current_user]
  146. self.clear_cookie("user")
  147. class RootHandler(BaseHandler):
  148. """Handler for the / path."""
  149. app_path = os.path.join(os.path.dirname(__file__), "dist")
  150. @gen.coroutine
  151. def get(self, *args, **kwargs):
  152. # serve the ./app/index.html file
  153. with open(self.app_path + "/index.html", 'r') as fd:
  154. self.write(fd.read())
  155. class CollectionHandler(BaseHandler):
  156. """Base class for handlers that need to interact with the database backend.
  157. Introduce basic CRUD operations."""
  158. # set of documents we're managing (a collection in MongoDB or a table in a SQL database)
  159. document = None
  160. collection = None
  161. # set of documents used to store incremental sequences
  162. counters_collection = 'counters'
  163. _id_chars = string.ascii_lowercase + string.digits
  164. def _filter_results(self, results, params):
  165. """Filter a list using keys and values from a dictionary.
  166. :param results: the list to be filtered
  167. :type results: list
  168. :param params: a dictionary of items that must all be present in an original list item to be included in the return
  169. :type params: dict
  170. :returns: list of items that have all the keys with the same values as params
  171. :rtype: list"""
  172. if not params:
  173. return results
  174. params = monco.convert(params)
  175. filtered = []
  176. for result in results:
  177. add = True
  178. for key, value in params.iteritems():
  179. if key not in result or result[key] != value:
  180. add = False
  181. break
  182. if add:
  183. filtered.append(result)
  184. return filtered
  185. def _clean_dict(self, data):
  186. """Filter a dictionary (in place) to remove unwanted keywords in db queries.
  187. :param data: dictionary to clean
  188. :type data: dict"""
  189. if isinstance(data, dict):
  190. for key in data.keys():
  191. if isinstance(key, (str, unicode)) and key.startswith('$'):
  192. del data[key]
  193. return data
  194. def apply_filter(self, data, filter_name):
  195. """Apply a filter to the data.
  196. :param data: the data to filter
  197. :returns: the modified (possibly also in place) data
  198. """
  199. filter_method = getattr(self, 'filter_%s' % filter_name, None)
  200. if filter_method is not None:
  201. data = filter_method(data)
  202. return data
  203. class AttendeesHandler(CollectionHandler):
  204. document = 'attendee'
  205. collection = 'attendees'
  206. @gen.coroutine
  207. def get(self, id_=None, **kwargs):
  208. if id_:
  209. output = self.db.getOne(self.collection, {'_id': id_})
  210. else:
  211. output = {self.collection: self.db.query(self.collection, self.arguments)}
  212. self.write(output)
  213. @gen.coroutine
  214. def post(self, **kwargs):
  215. data = escape.json_decode(self.request.body or '{}')
  216. self._clean_dict(data)
  217. user_info = self.current_user_info
  218. user_id = user_info.get('_id')
  219. now = datetime.datetime.now()
  220. data['created_by'] = user_id
  221. data['created_at'] = now
  222. data['updated_by'] = user_id
  223. data['updated_at'] = now
  224. doc = self.db.add(self.collection, data)
  225. doc = self.apply_filter(doc, 'create')
  226. self.write(doc)
  227. @gen.coroutine
  228. def put(self, id_, **kwargs):
  229. data = escape.json_decode(self.request.body or '{}')
  230. self._clean_dict(data)
  231. if '_id' in data:
  232. del data['_id']
  233. user_info = self.current_user_info
  234. user_id = user_info.get('_id')
  235. now = datetime.datetime.now()
  236. data['updated_by'] = user_id
  237. data['updated_at'] = now
  238. merged, doc = self.db.update(self.collection, {'_id': id_}, data)
  239. doc = self.apply_filter(doc, 'update')
  240. self.write(doc)
  241. @gen.coroutine
  242. def delete(self, id_=None, **kwargs):
  243. if id_ is not None:
  244. howMany = self.db.delete(self.collection, id_)
  245. self.write({'success': True, 'deleted entries': howMany.get('n')})
  246. else:
  247. self.write({'success': False})
  248. class DaysHandler(CollectionHandler):
  249. """Handle requests for Days."""
  250. def _summarize(self, days):
  251. res = []
  252. for day in days:
  253. res.append({'day': day['day'], 'groups_count': len(day.get('groups', []))})
  254. return res
  255. @gen.coroutine
  256. def get(self, day=None, **kwargs):
  257. params = self.arguments
  258. summary = params.get('summary', False)
  259. if summary:
  260. del params['summary']
  261. start = params.get('start')
  262. if start:
  263. del params['start']
  264. end = params.get('end')
  265. if end:
  266. del params['end']
  267. if day:
  268. params['day'] = day
  269. else:
  270. if start:
  271. params['day'] = {'$gte': start}
  272. if end:
  273. if 'day' not in params:
  274. params['day'] = {}
  275. if end.count('-') == 0:
  276. end += '-13'
  277. elif end.count('-') == 1:
  278. end += '-31'
  279. params['day'].update({'$lte': end})
  280. results = self.db.query('attendees', params)
  281. days = []
  282. dayData = {}
  283. try:
  284. sortedDays = []
  285. for result in results:
  286. if not ('day' in result and 'group' in result and 'name' in result):
  287. self.logger.warn('unable to parse entry; dayData: %s', dayData)
  288. continue
  289. sortedDays.append(result)
  290. sortedDays = sorted(sortedDays, key=itemgetter('day'))
  291. for d, dayItems in itertools.groupby(sortedDays, key=itemgetter('day')):
  292. dayData = {'day': d, 'groups': []}
  293. for group, attendees in itertools.groupby(sorted(dayItems, key=itemgetter('group')),
  294. key=itemgetter('group')):
  295. attendees = sorted(attendees, key=itemgetter('_id'))
  296. dayData['groups'].append({'group': group, 'attendees': attendees})
  297. days.append(dayData)
  298. except Exception as e:
  299. self.logger.warn('unable to parse entry; dayData: %s', dayData)
  300. if summary:
  301. days = self._summarize(days)
  302. if not day:
  303. self.write({'days': days})
  304. elif days:
  305. self.write(days[0])
  306. else:
  307. self.write({})
  308. class UsersHandler(CollectionHandler):
  309. """Handle requests for Users."""
  310. document = 'user'
  311. collection = 'users'
  312. def filter_get(self, data):
  313. if 'password' in data:
  314. del data['password']
  315. return data
  316. def filter_get_all(self, data):
  317. if 'users' not in data:
  318. return data
  319. for user in data['users']:
  320. if 'password' in user:
  321. del user['password']
  322. return data
  323. @gen.coroutine
  324. def get(self, id_=None, resource=None, resource_id=None, acl=True, **kwargs):
  325. super(UsersHandler, self).get(id_, resource, resource_id, acl=acl, **kwargs)
  326. def filter_input_post_all(self, data):
  327. username = (data.get('username') or '').strip()
  328. password = (data.get('password') or '').strip()
  329. email = (data.get('email') or '').strip()
  330. if not (username and password):
  331. raise InputException('missing username or password')
  332. res = self.db.query('users', {'username': username})
  333. if res:
  334. raise InputException('username already exists')
  335. return {'username': username, 'password': utils.hash_password(password),
  336. 'email': email}
  337. def filter_input_put(self, data):
  338. old_pwd = data.get('old_password')
  339. new_pwd = data.get('new_password')
  340. if old_pwd is not None:
  341. del data['old_password']
  342. if new_pwd is not None:
  343. del data['new_password']
  344. authorized, user = self.user_authorized(data['username'], old_pwd)
  345. if not (authorized and self.current_user == data['username']):
  346. raise InputException('not authorized to change password')
  347. data['password'] = utils.hash_password(new_pwd)
  348. if '_id' in data:
  349. # Avoid overriding _id
  350. del data['_id']
  351. return data
  352. @gen.coroutine
  353. def put(self, id_=None, resource=None, resource_id=None, **kwargs):
  354. if id_ is None:
  355. return self.build_error(status=404, message='unable to access the resource')
  356. if str(self.current_user_info.get('_id')) != id_:
  357. return self.build_error(status=401, message='insufficient permissions: current user')
  358. super(UsersHandler, self).put(id_, resource, resource_id, **kwargs)
  359. class SettingsHandler(BaseHandler):
  360. """Handle requests for Settings."""
  361. @gen.coroutine
  362. def get(self, **kwargs):
  363. query = self.arguments_tobool()
  364. settings = self.db.query('settings', query)
  365. self.write({'settings': settings})
  366. class CurrentUserHandler(BaseHandler):
  367. """Handle requests for information about the logged in user."""
  368. @gen.coroutine
  369. def get(self, **kwargs):
  370. user_info = self.current_user_info or {}
  371. if 'password' in user_info:
  372. del user_info['password']
  373. self.write(user_info)
  374. class LoginHandler(RootHandler):
  375. """Handle user authentication requests."""
  376. @gen.coroutine
  377. def get(self, **kwargs):
  378. # show the login page
  379. if self.is_api():
  380. self.set_status(401)
  381. self.write({'error': True,
  382. 'message': 'authentication required'})
  383. @gen.coroutine
  384. def post(self, *args, **kwargs):
  385. # authenticate a user
  386. try:
  387. password = self.get_body_argument('password')
  388. username = self.get_body_argument('username')
  389. except tornado.web.MissingArgumentError:
  390. data = escape.json_decode(self.request.body or '{}')
  391. username = data.get('username')
  392. password = data.get('password')
  393. if not (username and password):
  394. self.set_status(401)
  395. self.write({'error': True, 'message': 'missing username or password'})
  396. return
  397. authorized, user = self.user_authorized(username, password)
  398. if authorized and user.get('username'):
  399. username = user['username']
  400. logging.info('successful login for user %s' % username)
  401. self.set_secure_cookie("user", username)
  402. user_info = self.current_user_info
  403. if 'password' in user_info:
  404. del user_info['password']
  405. self.write(user_info)
  406. return
  407. logging.info('login failed for user %s' % username)
  408. self.set_status(401)
  409. self.write({'error': True, 'message': 'wrong username and password'})
  410. class LogoutHandler(BaseHandler):
  411. """Handle user logout requests."""
  412. @gen.coroutine
  413. def get(self, **kwargs):
  414. # log the user out
  415. logging.info('logout')
  416. self.logout()
  417. self.write({'error': False, 'message': 'logged out'})
  418. def run():
  419. """Run the Tornado web application."""
  420. # command line arguments; can also be written in a configuration file,
  421. # specified with the --config argument.
  422. define("port", default=3000, help="run on the given port", type=int)
  423. define("address", default='', help="bind the server at the given address", type=str)
  424. define("data_dir", default=os.path.join(os.path.dirname(__file__), "data"),
  425. help="specify the directory used to store the data")
  426. define("ssl_cert", default=os.path.join(os.path.dirname(__file__), 'ssl', 'ibt2_cert.pem'),
  427. help="specify the SSL certificate to use for secure connections")
  428. define("ssl_key", default=os.path.join(os.path.dirname(__file__), 'ssl', 'ibt2_key.pem'),
  429. help="specify the SSL private key to use for secure connections")
  430. define("mongo_url", default=None,
  431. help="URL to MongoDB server", type=str)
  432. define("db_name", default='ibt2',
  433. help="Name of the MongoDB database to use", type=str)
  434. define("authentication", default=False, help="if set to true, authentication is required")
  435. define("debug", default=False, help="run in debug mode")
  436. define("config", help="read configuration file",
  437. callback=lambda path: tornado.options.parse_config_file(path, final=False))
  438. tornado.options.parse_command_line()
  439. logger = logging.getLogger()
  440. logger.setLevel(logging.INFO)
  441. if options.debug:
  442. logger.setLevel(logging.DEBUG)
  443. ssl_options = {}
  444. if os.path.isfile(options.ssl_key) and os.path.isfile(options.ssl_cert):
  445. ssl_options = dict(certfile=options.ssl_cert, keyfile=options.ssl_key)
  446. # database backend connector
  447. db_connector = monco.Monco(url=options.mongo_url, dbName=options.db_name)
  448. init_params = dict(db=db_connector, data_dir=options.data_dir, listen_port=options.port,
  449. authentication=options.authentication, logger=logger, ssl_options=ssl_options)
  450. # If not present, we store a user 'admin' with password 'ibt2' into the database.
  451. if not db_connector.query('users', {'username': 'admin'}):
  452. db_connector.add('users',
  453. {'username': 'admin', 'password': utils.hash_password('ibt2'),
  454. 'isAdmin': True})
  455. # If present, use the cookie_secret stored into the database.
  456. cookie_secret = db_connector.query('settings', {'setting': 'server_cookie_secret'})
  457. if cookie_secret:
  458. cookie_secret = cookie_secret[0]['cookie_secret']
  459. else:
  460. # the salt guarantees its uniqueness
  461. cookie_secret = utils.hash_password('__COOKIE_SECRET__')
  462. db_connector.add('settings',
  463. {'setting': 'server_cookie_secret', 'cookie_secret': cookie_secret})
  464. _days_path = r"/days/?(?P<day>[\d_-]+)?"
  465. _attendees_path = r"/days/(?P<day_id>[\d_-]+)/groups/(?P<group_id>[\w\d_\ -]+)/attendees/?(?P<attendee_id>[\w\d_\ -]+)?"
  466. _current_user_path = r"/users/current/?"
  467. _users_path = r"/users/?(?P<id_>[\w\d_-]+)?/?(?P<resource>[\w\d_-]+)?/?(?P<resource_id>[\w\d_-]+)?"
  468. _attendees_path = r"/attendees/?(?P<id_>[\w\d_-]+)?"
  469. application = tornado.web.Application([
  470. (_attendees_path, AttendeesHandler, init_params),
  471. (r'/v%s%s' % (API_VERSION, _attendees_path), AttendeesHandler, init_params),
  472. (_days_path, DaysHandler, init_params),
  473. (r'/v%s%s' % (API_VERSION, _days_path), DaysHandler, init_params),
  474. (_current_user_path, CurrentUserHandler, init_params),
  475. (r'/v%s%s' % (API_VERSION, _current_user_path), CurrentUserHandler, init_params),
  476. (_users_path, UsersHandler, init_params),
  477. (r'/v%s%s' % (API_VERSION, _users_path), UsersHandler, init_params),
  478. (r"/(?:index.html)?", RootHandler, init_params),
  479. (r"/settings", SettingsHandler, init_params),
  480. (r'/login', LoginHandler, init_params),
  481. (r'/v%s/login' % API_VERSION, LoginHandler, init_params),
  482. (r'/logout', LogoutHandler),
  483. (r'/v%s/logout' % API_VERSION, LogoutHandler),
  484. (r'/?(.*)', tornado.web.StaticFileHandler, {"path": "dist"})
  485. ],
  486. static_path=os.path.join(os.path.dirname(__file__), "dist/static"),
  487. cookie_secret='__COOKIE_SECRET__',
  488. login_url='/login',
  489. debug=options.debug)
  490. http_server = tornado.httpserver.HTTPServer(application, ssl_options=ssl_options or None)
  491. logger.info('Start serving on %s://%s:%d', 'https' if ssl_options else 'http',
  492. options.address if options.address else '127.0.0.1',
  493. options.port)
  494. http_server.listen(options.port, options.address)
  495. tornado.ioloop.IOLoop.instance().start()
  496. if __name__ == '__main__':
  497. try:
  498. run()
  499. except KeyboardInterrupt:
  500. print('Stop server')