ibt2.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615
  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 logging
  17. import datetime
  18. from operator import itemgetter
  19. import itertools
  20. import tornado.httpserver
  21. import tornado.ioloop
  22. import tornado.options
  23. from tornado.options import define, options
  24. import tornado.web
  25. from tornado import gen, escape
  26. import utils
  27. import monco
  28. API_VERSION = '1.0'
  29. class BaseException(Exception):
  30. """Base class for ibt2 custom exceptions.
  31. :param message: text message
  32. :type message: str
  33. :param status: numeric http status code
  34. :type status: int"""
  35. def __init__(self, message, status=400):
  36. super(BaseException, self).__init__(message)
  37. self.message = message
  38. self.status = status
  39. class InputException(BaseException):
  40. """Exception raised by errors in input handling."""
  41. pass
  42. class BaseHandler(tornado.web.RequestHandler):
  43. """Base class for request handlers."""
  44. # Cache currently connected users.
  45. _users_cache = {}
  46. # set of documents we're managing (a collection in MongoDB or a table in a SQL database)
  47. document = None
  48. collection = None
  49. # A property to access the first value of each argument.
  50. arguments = property(lambda self: dict([(k, v[0])
  51. for k, v in self.request.arguments.iteritems()]))
  52. # Arguments suitable for a query on MongoDB.
  53. clean_arguments = property(lambda self: self._clean_dict(self.arguments))
  54. _re_split_salt = re.compile(r'\$(?P<salt>.+)\$(?P<hash>.+)')
  55. @property
  56. def clean_body(self):
  57. """Return a clean dictionary from a JSON body, suitable for a query on MongoDB.
  58. :returns: a clean copy of the body arguments
  59. :rtype: dict"""
  60. data = escape.json_decode(self.request.body or '{}')
  61. return self._clean_dict(data)
  62. def _clean_dict(self, data):
  63. """Filter a dictionary (in place) to remove unwanted keywords in db queries.
  64. :param data: dictionary to clean
  65. :type data: dict"""
  66. if isinstance(data, dict):
  67. for key in data.keys():
  68. if (isinstance(key, (str, unicode)) and key.startswith('$')) or key in ('_id', 'created_by',
  69. 'created_at', 'updated_by',
  70. 'updated_at'):
  71. del data[key]
  72. return data
  73. def write_error(self, status_code, **kwargs):
  74. """Default error handler."""
  75. if isinstance(kwargs.get('exc_info', (None, None))[1], BaseException):
  76. exc = kwargs['exc_info'][1]
  77. status_code = exc.status
  78. message = exc.message
  79. else:
  80. message = 'internal error'
  81. self.build_error(message, status=status_code)
  82. def is_api(self):
  83. """Return True if the path is from an API call."""
  84. return self.request.path.startswith('/v%s' % API_VERSION)
  85. def initialize(self, **kwargs):
  86. """Add every passed (key, value) as attributes of the instance."""
  87. for key, value in kwargs.iteritems():
  88. setattr(self, key, value)
  89. @property
  90. def current_user(self):
  91. """Retrieve current user ID from the secure cookie."""
  92. return self.get_secure_cookie("user")
  93. @property
  94. def current_user_info(self):
  95. """Information about the current user.
  96. :returns: full information about the current user
  97. :rtype: dict"""
  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['_id'] = current_user
  104. user = self.db.getOne('users', {'_id': user_info['_id']})
  105. if user:
  106. user_info = user
  107. self._users_cache[current_user] = user_info
  108. return user_info
  109. def user_authorized(self, username, password):
  110. """Check if a combination of username/password is valid.
  111. :param username: username or email
  112. :type username: str
  113. :param password: password
  114. :type password: str
  115. :returns: tuple like (bool_user_is_authorized, dict_user_info)
  116. :rtype: dict"""
  117. query = [{'username': username}, {'email': username}]
  118. res = self.db.query('users', query)
  119. if not res:
  120. return (False, {})
  121. user = res[0]
  122. db_password = user.get('password') or ''
  123. if not db_password:
  124. return (False, {})
  125. match = self._re_split_salt.match(db_password)
  126. if not match:
  127. return (False, {})
  128. salt = match.group('salt')
  129. if utils.hash_password(password, salt=salt) == db_password:
  130. return (True, user)
  131. return (False, {})
  132. def build_error(self, message='', status=400):
  133. """Build and write an error message.
  134. :param message: textual message
  135. :type message: str
  136. :param status: HTTP status code
  137. :type status: int
  138. """
  139. self.set_status(status)
  140. self.write({'error': True, 'message': message})
  141. def has_permission(self, owner_id):
  142. """Check if the given owner_id matches with the current user or the logged in user is an admin; if not,
  143. build an error reply.
  144. :param owner_id: owner ID to check against
  145. :type owner_id: str, ObjectId, None
  146. :returns: if the logged in user has the permission
  147. :rtype: bool"""
  148. if (owner_id and str(self.current_user) != str(owner_id) and not
  149. self.current_user_info.get('isAdmin')):
  150. self.build_error(status=401, message='insufficient permissions: must be the owner or admin')
  151. return False
  152. return True
  153. def logout(self):
  154. """Remove the secure cookie used fro authentication."""
  155. if self.current_user in self._users_cache:
  156. del self._users_cache[self.current_user]
  157. self.clear_cookie("user")
  158. def add_access_info(self, doc):
  159. """Add created/updated by/at to a document (modified in place and returned).
  160. :param doc: the doc to be updated
  161. :type doc: dict
  162. :returns: the updated document
  163. :rtype: dict"""
  164. user_id = self.current_user
  165. now = datetime.datetime.now()
  166. if 'created_by' not in doc:
  167. doc['created_by'] = user_id
  168. if 'created_at' not in doc:
  169. doc['created_at'] = now
  170. doc['updated_by'] = user_id
  171. doc['updated_at'] = now
  172. return doc
  173. class RootHandler(BaseHandler):
  174. """Handler for the / path."""
  175. app_path = os.path.join(os.path.dirname(__file__), "dist")
  176. @gen.coroutine
  177. def get(self, *args, **kwargs):
  178. # serve the ./app/index.html file
  179. with open(self.app_path + "/index.html", 'r') as fd:
  180. self.write(fd.read())
  181. class AttendeesHandler(BaseHandler):
  182. document = 'attendee'
  183. collection = 'attendees'
  184. @gen.coroutine
  185. def get(self, id_=None, **kwargs):
  186. if id_:
  187. output = self.db.getOne(self.collection, {'_id': id_})
  188. else:
  189. output = {self.collection: self.db.query(self.collection, self.clean_arguments)}
  190. self.write(output)
  191. @gen.coroutine
  192. def post(self, **kwargs):
  193. data = self.clean_body
  194. for key in 'name', 'group', 'day':
  195. value = (data.get(key) or '').strip()
  196. if not value:
  197. return self.build_error(status=404, message="%s can't be empty" % key)
  198. data[key] = value
  199. self.add_access_info(data)
  200. doc = self.db.add(self.collection, data)
  201. self.write(doc)
  202. @gen.coroutine
  203. def put(self, id_, **kwargs):
  204. data = self.clean_body
  205. doc = self.db.getOne(self.collection, {'_id': id_}) or {}
  206. if not doc:
  207. return self.build_error(status=404, message='unable to access the resource')
  208. if not self.has_permission(doc.get('created_by')):
  209. return
  210. self.add_access_info(data)
  211. merged, doc = self.db.update(self.collection, {'_id': id_}, data)
  212. self.write(doc)
  213. @gen.coroutine
  214. def delete(self, id_=None, **kwargs):
  215. if id_ is None:
  216. self.write({'success': False})
  217. return
  218. doc = self.db.getOne(self.collection, {'_id': id_}) or {}
  219. if not doc:
  220. return self.build_error(status=404, message='unable to access the resource')
  221. if not self.has_permission(doc.get('created_by')):
  222. return
  223. howMany = self.db.delete(self.collection, id_)
  224. self.write({'success': True, 'deleted entries': howMany.get('n')})
  225. class DaysHandler(BaseHandler):
  226. """Handle requests for Days."""
  227. def _summarize(self, days):
  228. res = []
  229. for day in days:
  230. res.append({'day': day['day'], 'groups_count': len(day.get('groups', []))})
  231. return res
  232. @gen.coroutine
  233. def get(self, day=None, **kwargs):
  234. params = self.clean_arguments
  235. summary = params.get('summary', False)
  236. if summary:
  237. del params['summary']
  238. start = params.get('start')
  239. if start:
  240. del params['start']
  241. end = params.get('end')
  242. if end:
  243. del params['end']
  244. base = {}
  245. groupsDetails = {}
  246. if day:
  247. params['day'] = day
  248. base = self.db.getOne('days', {'day': day})
  249. groupsDetails = dict([(x['group'], x) for x in self.db.query('groups', {'day': day})])
  250. else:
  251. if start:
  252. params['day'] = {'$gte': start}
  253. if end:
  254. if 'day' not in params:
  255. params['day'] = {}
  256. if end.count('-') == 0:
  257. end += '-13'
  258. elif end.count('-') == 1:
  259. end += '-31'
  260. params['day'].update({'$lte': end})
  261. results = self.db.query('attendees', params)
  262. days = []
  263. dayData = {}
  264. try:
  265. sortedDays = []
  266. for result in results:
  267. if not ('day' in result and 'group' in result and 'name' in result):
  268. self.logger.warn('unable to parse entry; dayData: %s', dayData)
  269. continue
  270. sortedDays.append(result)
  271. sortedDays = sorted(sortedDays, key=itemgetter('day'))
  272. for d, dayItems in itertools.groupby(sortedDays, key=itemgetter('day')):
  273. dayData = {'day': d, 'groups': []}
  274. for group, attendees in itertools.groupby(sorted(dayItems, key=itemgetter('group')),
  275. key=itemgetter('group')):
  276. attendees = sorted(attendees, key=itemgetter('_id'))
  277. groupData = groupsDetails.get(group) or {}
  278. groupData.update({'group': group, 'attendees': attendees})
  279. dayData['groups'].append(groupData)
  280. days.append(dayData)
  281. except Exception as e:
  282. self.logger.warn('unable to parse entry; dayData: %s error: %s', dayData, e)
  283. if summary:
  284. days = self._summarize(days)
  285. if not day:
  286. self.write({'days': days})
  287. elif days:
  288. base.update(days[0])
  289. self.write(base)
  290. else:
  291. self.write(base)
  292. @gen.coroutine
  293. def put(self, **kwargs):
  294. data = self.clean_body
  295. day = (data.get('day') or '').strip()
  296. if not day:
  297. return self.build_error(status=404, message='unable to access the resource')
  298. data['day'] = day
  299. self.add_access_info(data)
  300. merged, doc = self.db.update('days', {'day': day}, data)
  301. self.write(doc)
  302. class GroupsHandler(BaseHandler):
  303. """Handle requests for Groups."""
  304. @gen.coroutine
  305. def put(self, **kwargs):
  306. data = self.clean_body
  307. day = (data.get('day') or '').strip()
  308. group = (data.get('group') or '').strip()
  309. if not (day and group):
  310. return self.build_error(status=404, message='unable to access the resource')
  311. data['day'] = day
  312. data['group'] = group
  313. self.add_access_info(data)
  314. merged, doc = self.db.update('groups', {'day': day, 'group': group}, data)
  315. self.write(doc)
  316. @gen.coroutine
  317. def delete(self, **kwargs):
  318. data = self.clean_arguments
  319. day = (data.get('day') or '').strip()
  320. group = (data.get('group') or '').strip()
  321. if not (day and group):
  322. return self.build_error(status=404, message='unable to access the resource')
  323. if not self.current_user_info.get('isAdmin'):
  324. self.build_error(status=401, message='insufficient permissions: must be admin')
  325. return False
  326. query = {'day': day, 'group': group}
  327. howMany = self.db.delete('attendees', query)
  328. self.db.delete('groups', query)
  329. self.write({'success': True, 'deleted entries': howMany.get('n')})
  330. class UsersHandler(BaseHandler):
  331. """Handle requests for Users."""
  332. document = 'user'
  333. collection = 'users'
  334. @gen.coroutine
  335. def get(self, id_=None, **kwargs):
  336. if id_:
  337. if not self.has_permission(id_):
  338. return
  339. output = self.db.getOne(self.collection, {'_id': id_})
  340. if 'password' in output:
  341. del output['password']
  342. else:
  343. if not self.current_user_info.get('isAdmin'):
  344. return self.build_error(status=401, message='insufficient permissions: must be an admin')
  345. output = {self.collection: self.db.query(self.collection, self.clean_arguments)}
  346. for user in output['users']:
  347. if 'password' in user:
  348. del user['password']
  349. self.write(output)
  350. @gen.coroutine
  351. def post(self, **kwargs):
  352. data = self.clean_body
  353. username = (data.get('username') or '').strip()
  354. password = (data.get('password') or '').strip()
  355. email = (data.get('email') or '').strip()
  356. if not (username and password):
  357. raise InputException('missing username or password')
  358. res = self.db.query('users', {'username': username})
  359. if res:
  360. raise InputException('username already exists')
  361. data['username'] = username
  362. data['password'] = utils.hash_password(password)
  363. data['email'] = email
  364. self.add_access_info(data)
  365. if 'isAdmin' in data and not self.current_user_info.get('isAdmin'):
  366. del data['isAdmin']
  367. doc = self.db.add(self.collection, data)
  368. if 'password' in doc:
  369. del doc['password']
  370. self.write(doc)
  371. @gen.coroutine
  372. def put(self, id_=None, **kwargs):
  373. data = self.clean_body
  374. if id_ is None:
  375. return self.build_error(status=404, message='unable to access the resource')
  376. if not self.has_permission(id_):
  377. return
  378. if 'username' in data:
  379. del data['username']
  380. if 'isAdmin' in data and (str(self.current_user) == id_ or not self.current_user_info.get('isAdmin')):
  381. del data['isAdmin']
  382. if 'password' in data:
  383. password = (data['password'] or '').strip()
  384. if password:
  385. data['password'] = utils.hash_password(password)
  386. else:
  387. del data['password']
  388. self.add_access_info(data)
  389. merged, doc = self.db.update(self.collection, {'_id': id_}, data)
  390. if 'password' in doc:
  391. del doc['password']
  392. self.write(doc)
  393. @gen.coroutine
  394. def delete(self, id_=None, **kwargs):
  395. if id_ is None:
  396. return self.build_error(status=404, message='unable to access the resource')
  397. if not self.has_permission(id_):
  398. return
  399. howMany = self.db.delete(self.collection, id_)
  400. if id_ in self._users_cache:
  401. del self._users_cache[id_]
  402. self.write({'success': True, 'deleted entries': howMany.get('n')})
  403. class CurrentUserHandler(BaseHandler):
  404. """Handle requests for information about the logged in user."""
  405. @gen.coroutine
  406. def get(self, **kwargs):
  407. user_info = self.current_user_info or {}
  408. if 'password' in user_info:
  409. del user_info['password']
  410. self.write(user_info)
  411. class LoginHandler(RootHandler):
  412. """Handle user authentication requests."""
  413. @gen.coroutine
  414. def get(self, **kwargs):
  415. # show the login page
  416. if self.is_api():
  417. self.set_status(401)
  418. self.write({'error': True,
  419. 'message': 'authentication required'})
  420. @gen.coroutine
  421. def post(self, *args, **kwargs):
  422. # authenticate a user
  423. try:
  424. password = self.get_body_argument('password')
  425. username = self.get_body_argument('username')
  426. except tornado.web.MissingArgumentError:
  427. data = self.clean_body
  428. username = data.get('username')
  429. password = data.get('password')
  430. if not (username and password):
  431. self.set_status(401)
  432. self.write({'error': True, 'message': 'missing username or password'})
  433. return
  434. authorized, user = self.user_authorized(username, password)
  435. if authorized and 'username' in user and '_id' in user:
  436. id_ = str(user['_id'])
  437. username = user['username']
  438. logging.info('successful login for user %s (id: %s)' % (username, id_))
  439. self.set_secure_cookie("user", id_)
  440. user_info = self.current_user_info
  441. if 'password' in user_info:
  442. del user_info['password']
  443. self.write(user_info)
  444. return
  445. logging.info('login failed for user %s' % username)
  446. self.set_status(401)
  447. self.write({'error': True, 'message': 'wrong username and password'})
  448. class LogoutHandler(BaseHandler):
  449. """Handle user logout requests."""
  450. @gen.coroutine
  451. def get(self, **kwargs):
  452. # log the user out
  453. logging.info('logout')
  454. self.logout()
  455. self.write({'error': False, 'message': 'logged out'})
  456. def run():
  457. """Run the Tornado web application."""
  458. # command line arguments; can also be written in a configuration file,
  459. # specified with the --config argument.
  460. define("port", default=3000, help="run on the given port", type=int)
  461. define("address", default='', help="bind the server at the given address", type=str)
  462. define("ssl_cert", default=os.path.join(os.path.dirname(__file__), 'ssl', 'ibt2_cert.pem'),
  463. help="specify the SSL certificate to use for secure connections")
  464. define("ssl_key", default=os.path.join(os.path.dirname(__file__), 'ssl', 'ibt2_key.pem'),
  465. help="specify the SSL private key to use for secure connections")
  466. define("mongo_url", default=None,
  467. help="URL to MongoDB server", type=str)
  468. define("db_name", default='ibt2',
  469. help="Name of the MongoDB database to use", type=str)
  470. define("debug", default=False, help="run in debug mode")
  471. define("config", help="read configuration file",
  472. callback=lambda path: tornado.options.parse_config_file(path, final=False))
  473. tornado.options.parse_command_line()
  474. logger = logging.getLogger()
  475. logger.setLevel(logging.INFO)
  476. if options.debug:
  477. logger.setLevel(logging.DEBUG)
  478. ssl_options = {}
  479. if os.path.isfile(options.ssl_key) and os.path.isfile(options.ssl_cert):
  480. ssl_options = dict(certfile=options.ssl_cert, keyfile=options.ssl_key)
  481. # database backend connector
  482. db_connector = monco.Monco(url=options.mongo_url, dbName=options.db_name)
  483. init_params = dict(db=db_connector, listen_port=options.port, logger=logger, ssl_options=ssl_options)
  484. # If not present, we store a user 'admin' with password 'ibt2' into the database.
  485. if not db_connector.query('users', {'username': 'admin'}):
  486. db_connector.add('users',
  487. {'username': 'admin', 'password': utils.hash_password('ibt2'),
  488. 'isAdmin': True})
  489. # If present, use the cookie_secret stored into the database.
  490. cookie_secret = db_connector.query('settings', {'setting': 'server_cookie_secret'})
  491. if cookie_secret:
  492. cookie_secret = cookie_secret[0]['cookie_secret']
  493. else:
  494. # the salt guarantees its uniqueness
  495. cookie_secret = utils.hash_password('__COOKIE_SECRET__')
  496. db_connector.add('settings',
  497. {'setting': 'server_cookie_secret', 'cookie_secret': cookie_secret})
  498. _days_path = r"/days/?(?P<day>[\d_-]+)?"
  499. _groups_path = r"/groups/?"
  500. _attendees_path = r"/attendees/?(?P<id_>[\w\d_-]+)?"
  501. _current_user_path = r"/users/current/?"
  502. _users_path = r"/users/?(?P<id_>[\w\d_-]+)?/?(?P<resource>[\w\d_-]+)?/?(?P<resource_id>[\w\d_-]+)?"
  503. application = tornado.web.Application([
  504. (_attendees_path, AttendeesHandler, init_params),
  505. (r'/v%s%s' % (API_VERSION, _attendees_path), AttendeesHandler, init_params),
  506. (_days_path, DaysHandler, init_params),
  507. (r'/v%s%s' % (API_VERSION, _groups_path), GroupsHandler, init_params),
  508. (_groups_path, GroupsHandler, init_params),
  509. (r'/v%s%s' % (API_VERSION, _days_path), DaysHandler, init_params),
  510. (_current_user_path, CurrentUserHandler, init_params),
  511. (r'/v%s%s' % (API_VERSION, _current_user_path), CurrentUserHandler, init_params),
  512. (_users_path, UsersHandler, init_params),
  513. (r'/v%s%s' % (API_VERSION, _users_path), UsersHandler, init_params),
  514. (r"/(?:index.html)?", RootHandler, init_params),
  515. (r'/login', LoginHandler, init_params),
  516. (r'/v%s/login' % API_VERSION, LoginHandler, init_params),
  517. (r'/logout', LogoutHandler),
  518. (r'/v%s/logout' % API_VERSION, LogoutHandler),
  519. (r'/?(.*)', tornado.web.StaticFileHandler, {"path": "dist"})
  520. ],
  521. static_path=os.path.join(os.path.dirname(__file__), "dist/static"),
  522. cookie_secret='__COOKIE_SECRET__',
  523. login_url='/login',
  524. debug=options.debug)
  525. http_server = tornado.httpserver.HTTPServer(application, ssl_options=ssl_options or None)
  526. logger.info('Start serving on %s://%s:%d', 'https' if ssl_options else 'http',
  527. options.address if options.address else '127.0.0.1',
  528. options.port)
  529. http_server.listen(options.port, options.address)
  530. tornado.ioloop.IOLoop.instance().start()
  531. if __name__ == '__main__':
  532. try:
  533. run()
  534. except KeyboardInterrupt:
  535. print('Stop server')