Commit 3259fc7caed421d812eaf44b7a425062fc607bc1
1 parent
5dae401a
Exists in
master
and in
1 other branch
- Modified login to prevent timing attacks.
- Sanity checks now support tests_right and tests_wrong in questions (unit testing for questions) - Application name is defined in __init__.py only, so that changing name becomes easier.
Showing
13 changed files
with
97 additions
and
69 deletions
Show diff stats
aprendizations/initdb.py
| 1 | 1 | #!/usr/bin/env python3 |
| 2 | 2 | |
| 3 | -# base | |
| 3 | +# python standard libraries | |
| 4 | 4 | import csv |
| 5 | 5 | import argparse |
| 6 | 6 | import re |
| 7 | 7 | from string import capwords |
| 8 | 8 | from concurrent.futures import ThreadPoolExecutor |
| 9 | 9 | |
| 10 | -# installed packages | |
| 10 | +# third party libraries | |
| 11 | 11 | import bcrypt |
| 12 | 12 | import sqlalchemy as sa |
| 13 | 13 | |
| 14 | 14 | # this project |
| 15 | -from aprendizations.models import Base, Student | |
| 15 | +from .models import Base, Student | |
| 16 | 16 | |
| 17 | 17 | |
| 18 | 18 | # =========================================================================== | ... | ... |
aprendizations/knowledge.py
| ... | ... | @@ -5,12 +5,9 @@ from datetime import datetime |
| 5 | 5 | import logging |
| 6 | 6 | import asyncio |
| 7 | 7 | |
| 8 | -# libraries | |
| 8 | +# third party libraries | |
| 9 | 9 | import networkx as nx |
| 10 | 10 | |
| 11 | -# this project | |
| 12 | -# import questions | |
| 13 | - | |
| 14 | 11 | # setup logger for this module |
| 15 | 12 | logger = logging.getLogger(__name__) |
| 16 | 13 | ... | ... |
aprendizations/learnapp.py
| ... | ... | @@ -5,41 +5,25 @@ import logging |
| 5 | 5 | from contextlib import contextmanager # `with` statement in db sessions |
| 6 | 6 | import asyncio |
| 7 | 7 | from datetime import datetime |
| 8 | +from random import random | |
| 8 | 9 | |
| 9 | -# user installed libraries | |
| 10 | +# third party libraries | |
| 10 | 11 | import bcrypt |
| 11 | 12 | from sqlalchemy import create_engine, func |
| 12 | 13 | from sqlalchemy.orm import sessionmaker |
| 13 | 14 | import networkx as nx |
| 14 | 15 | |
| 15 | 16 | # this project |
| 16 | -from aprendizations.models import Student, Answer, Topic, StudentTopic | |
| 17 | -from aprendizations.knowledge import StudentKnowledge | |
| 18 | -from aprendizations.questions import QFactory | |
| 19 | -from aprendizations.tools import load_yaml | |
| 17 | +from .models import Student, Answer, Topic, StudentTopic | |
| 18 | +from .knowledge import StudentKnowledge | |
| 19 | +from .questions import QFactory | |
| 20 | +from .tools import load_yaml | |
| 20 | 21 | |
| 21 | 22 | # setup logger for this module |
| 22 | 23 | logger = logging.getLogger(__name__) |
| 23 | 24 | |
| 24 | 25 | |
| 25 | 26 | # ============================================================================ |
| 26 | -# helper functions | |
| 27 | -# ============================================================================ | |
| 28 | -async def _bcrypt_hash(a, b): | |
| 29 | - loop = asyncio.get_running_loop() | |
| 30 | - return await loop.run_in_executor(None, bcrypt.hashpw, | |
| 31 | - a.encode('utf-8'), b) | |
| 32 | - | |
| 33 | - | |
| 34 | -async def check_password(try_pw: str, pw: str) -> bool: | |
| 35 | - return pw == await _bcrypt_hash(try_pw, pw) | |
| 36 | - | |
| 37 | - | |
| 38 | -async def bcrypt_hash_gen(new_pw: str): | |
| 39 | - return await _bcrypt_hash(new_pw, bcrypt.gensalt()) | |
| 40 | - | |
| 41 | - | |
| 42 | -# ============================================================================ | |
| 43 | 27 | class LearnException(Exception): |
| 44 | 28 | pass |
| 45 | 29 | |
| ... | ... | @@ -86,38 +70,74 @@ class LearnApp(object): |
| 86 | 70 | |
| 87 | 71 | # ------------------------------------------------------------------------ |
| 88 | 72 | def sanity_check_questions(self): |
| 89 | - def check_question(qref, q): | |
| 90 | - logger.debug(f'Generating {qref}...') | |
| 91 | - try: | |
| 92 | - q.generate() | |
| 93 | - except Exception as e: | |
| 94 | - logger.error(f'Sanity check failed in "{qref}"') | |
| 95 | - raise e | |
| 73 | + logger.info('Starting sanity checks (may take a while...)') | |
| 96 | 74 | |
| 97 | - logger.info('Starting sanity checks...') | |
| 98 | - for qref, q in self.factory.items(): | |
| 99 | - check_question(qref, q) | |
| 100 | - logger.info('Sanity checks passed.') | |
| 75 | + errors = 0 | |
| 76 | + for qref in self.factory: | |
| 77 | + logger.debug(f'Checking "{qref}"...') | |
| 78 | + q = self.factory[qref].generate() | |
| 79 | + try: | |
| 80 | + q = self.factory[qref].generate() | |
| 81 | + except Exception: | |
| 82 | + logger.error(f'Failed to generate "{qref}".') | |
| 83 | + errors += 1 | |
| 84 | + raise | |
| 85 | + continue | |
| 86 | + | |
| 87 | + if 'tests_right' in q: | |
| 88 | + for t in q['tests_right']: | |
| 89 | + q['answer'] = t | |
| 90 | + q.correct() | |
| 91 | + if q['grade'] < 1.0: | |
| 92 | + logger.error(f'Failed to correct right answer in ' | |
| 93 | + f'"{qref}".') | |
| 94 | + errors += 1 | |
| 95 | + continue # to next right test | |
| 96 | + | |
| 97 | + if 'tests_wrong' in q: | |
| 98 | + for t in q['tests_wrong']: | |
| 99 | + q['answer'] = t | |
| 100 | + q.correct() | |
| 101 | + if q['grade'] >= 1.0: | |
| 102 | + logger.error(f'Failed to correct right answer in ' | |
| 103 | + f'"{qref}".') | |
| 104 | + errors += 1 | |
| 105 | + continue # to next wrong test | |
| 106 | + | |
| 107 | + if errors > 0: | |
| 108 | + logger.info(f'{errors:>6} errors found.') | |
| 109 | + raise | |
| 110 | + else: | |
| 111 | + logger.info('No errors found.') | |
| 101 | 112 | |
| 102 | 113 | # ------------------------------------------------------------------------ |
| 103 | 114 | # login |
| 104 | 115 | # ------------------------------------------------------------------------ |
| 105 | - async def login(self, uid, try_pw): | |
| 116 | + async def login(self, uid, pw): | |
| 106 | 117 | with self.db_session() as s: |
| 107 | - try: | |
| 108 | - name, password = s.query(Student.name, Student.password) \ | |
| 109 | - .filter_by(id=uid) \ | |
| 110 | - .one() | |
| 111 | - except Exception: | |
| 112 | - logger.info(f'User "{uid}" does not exist') | |
| 113 | - return False | |
| 118 | + found = s.query(Student.name, Student.password) \ | |
| 119 | + .filter_by(id=uid) \ | |
| 120 | + .one_or_none() | |
| 121 | + | |
| 122 | + # wait random time to minimize timing attacks | |
| 123 | + await asyncio.sleep(random()) | |
| 124 | + | |
| 125 | + loop = asyncio.get_running_loop() | |
| 126 | + if found is None: | |
| 127 | + logger.info(f'User "{uid}" does not exist') | |
| 128 | + await loop.run_in_executor(None, bcrypt.hashpw, b'', | |
| 129 | + bcrypt.gensalt()) # just spend time | |
| 130 | + return False | |
| 114 | 131 | |
| 115 | - pw_ok = await check_password(try_pw, password) # async bcrypt | |
| 132 | + else: | |
| 133 | + name, hashed_pw = found | |
| 134 | + pw_ok = await loop.run_in_executor(None, bcrypt.checkpw, | |
| 135 | + pw.encode('utf-8'), hashed_pw) | |
| 116 | 136 | |
| 117 | 137 | if pw_ok: |
| 118 | 138 | if uid in self.online: |
| 119 | - logger.warning(f'User "{uid}" already logged in, overwriting') | |
| 120 | - counter = self.online[uid]['counter'] # simultaneous logins | |
| 139 | + logger.warning(f'User "{uid}" already logged in') | |
| 140 | + counter = self.online[uid]['counter'] | |
| 121 | 141 | else: |
| 122 | 142 | logger.info(f'User "{uid}" logged in') |
| 123 | 143 | counter = 0 |
| ... | ... | @@ -158,7 +178,9 @@ class LearnApp(object): |
| 158 | 178 | if not pw: |
| 159 | 179 | return False |
| 160 | 180 | |
| 161 | - pw = await bcrypt_hash_gen(pw) | |
| 181 | + loop = asyncio.get_running_loop() | |
| 182 | + pw = await loop.run_in_executor(None, bcrypt.hashpw, | |
| 183 | + pw.encode('utf-8'), bcrypt.gensalt()) | |
| 162 | 184 | |
| 163 | 185 | with self.db_session() as s: |
| 164 | 186 | u = s.query(Student).get(uid) | ... | ... |
aprendizations/models.py
aprendizations/questions.py
aprendizations/redirect.py
aprendizations/serve.py
| ... | ... | @@ -14,16 +14,16 @@ import functools |
| 14 | 14 | import ssl |
| 15 | 15 | import asyncio |
| 16 | 16 | |
| 17 | -# user installed libraries | |
| 17 | +# third party libraries | |
| 18 | 18 | import tornado.ioloop |
| 19 | 19 | import tornado.web |
| 20 | 20 | import tornado.httpserver |
| 21 | 21 | from tornado.escape import to_unicode |
| 22 | 22 | |
| 23 | 23 | # this project |
| 24 | -from aprendizations.learnapp import LearnApp | |
| 25 | -from aprendizations.tools import load_yaml, md_to_html | |
| 26 | -from aprendizations import APP_NAME | |
| 24 | +from .learnapp import LearnApp | |
| 25 | +from .tools import load_yaml, md_to_html | |
| 26 | +from . import APP_NAME | |
| 27 | 27 | |
| 28 | 28 | |
| 29 | 29 | # ---------------------------------------------------------------------------- |
| ... | ... | @@ -97,6 +97,7 @@ class RankingsHandler(BaseHandler): |
| 97 | 97 | uid = self.current_user |
| 98 | 98 | rankings = self.learn.get_rankings(uid) |
| 99 | 99 | self.render('rankings.html', |
| 100 | + appname=APP_NAME, | |
| 100 | 101 | uid=uid, |
| 101 | 102 | name=self.learn.get_student_name(uid), |
| 102 | 103 | rankings=rankings) |
| ... | ... | @@ -107,7 +108,9 @@ class RankingsHandler(BaseHandler): |
| 107 | 108 | # ---------------------------------------------------------------------------- |
| 108 | 109 | class LoginHandler(BaseHandler): |
| 109 | 110 | def get(self): |
| 110 | - self.render('login.html', error='') | |
| 111 | + self.render('login.html', | |
| 112 | + appname=APP_NAME, | |
| 113 | + error='') | |
| 111 | 114 | |
| 112 | 115 | async def post(self): |
| 113 | 116 | uid = self.get_body_argument('uid').lstrip('l') |
| ... | ... | @@ -121,7 +124,9 @@ class LoginHandler(BaseHandler): |
| 121 | 124 | self.set_secure_cookie('counter', counter) |
| 122 | 125 | self.redirect('/') |
| 123 | 126 | else: |
| 124 | - self.render('login.html', error='Número ou senha incorrectos') | |
| 127 | + self.render('login.html', | |
| 128 | + appname=APP_NAME, | |
| 129 | + error='Número ou senha incorrectos') | |
| 125 | 130 | |
| 126 | 131 | |
| 127 | 132 | # ---------------------------------------------------------------------------- |
| ... | ... | @@ -168,6 +173,7 @@ class RootHandler(BaseHandler): |
| 168 | 173 | def get(self): |
| 169 | 174 | uid = self.current_user |
| 170 | 175 | self.render('maintopics-table.html', |
| 176 | + appname=APP_NAME, | |
| 171 | 177 | uid=uid, |
| 172 | 178 | name=self.learn.get_student_name(uid), |
| 173 | 179 | state=self.learn.get_student_state(uid), |
| ... | ... | @@ -193,6 +199,7 @@ class TopicHandler(BaseHandler): |
| 193 | 199 | self.redirect('/') |
| 194 | 200 | else: |
| 195 | 201 | self.render('topic.html', |
| 202 | + appname=APP_NAME, | |
| 196 | 203 | uid=uid, |
| 197 | 204 | name=self.learn.get_student_name(uid), |
| 198 | 205 | ) |
| ... | ... | @@ -201,9 +208,6 @@ class TopicHandler(BaseHandler): |
| 201 | 208 | # ---------------------------------------------------------------------------- |
| 202 | 209 | # Serves files from the /public subdir of the topics. |
| 203 | 210 | # ---------------------------------------------------------------------------- |
| 204 | - | |
| 205 | -# FIXME error in many situations... images are not shown... | |
| 206 | -# seems to happen when the browser sends two GET requests at the same time | |
| 207 | 211 | class FileHandler(BaseHandler): |
| 208 | 212 | SUPPORTED_METHODS = ['GET'] |
| 209 | 213 | ... | ... |
aprendizations/templates/login.html
aprendizations/templates/maintopics-table.html
aprendizations/templates/rankings.html
aprendizations/templates/topic.html
aprendizations/tools.py