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 | #!/usr/bin/env python3 | 1 | #!/usr/bin/env python3 |
| 2 | 2 | ||
| 3 | -# base | 3 | +# python standard libraries |
| 4 | import csv | 4 | import csv |
| 5 | import argparse | 5 | import argparse |
| 6 | import re | 6 | import re |
| 7 | from string import capwords | 7 | from string import capwords |
| 8 | from concurrent.futures import ThreadPoolExecutor | 8 | from concurrent.futures import ThreadPoolExecutor |
| 9 | 9 | ||
| 10 | -# installed packages | 10 | +# third party libraries |
| 11 | import bcrypt | 11 | import bcrypt |
| 12 | import sqlalchemy as sa | 12 | import sqlalchemy as sa |
| 13 | 13 | ||
| 14 | # this project | 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,12 +5,9 @@ from datetime import datetime | ||
| 5 | import logging | 5 | import logging |
| 6 | import asyncio | 6 | import asyncio |
| 7 | 7 | ||
| 8 | -# libraries | 8 | +# third party libraries |
| 9 | import networkx as nx | 9 | import networkx as nx |
| 10 | 10 | ||
| 11 | -# this project | ||
| 12 | -# import questions | ||
| 13 | - | ||
| 14 | # setup logger for this module | 11 | # setup logger for this module |
| 15 | logger = logging.getLogger(__name__) | 12 | logger = logging.getLogger(__name__) |
| 16 | 13 |
aprendizations/learnapp.py
| @@ -5,41 +5,25 @@ import logging | @@ -5,41 +5,25 @@ import logging | ||
| 5 | from contextlib import contextmanager # `with` statement in db sessions | 5 | from contextlib import contextmanager # `with` statement in db sessions |
| 6 | import asyncio | 6 | import asyncio |
| 7 | from datetime import datetime | 7 | from datetime import datetime |
| 8 | +from random import random | ||
| 8 | 9 | ||
| 9 | -# user installed libraries | 10 | +# third party libraries |
| 10 | import bcrypt | 11 | import bcrypt |
| 11 | from sqlalchemy import create_engine, func | 12 | from sqlalchemy import create_engine, func |
| 12 | from sqlalchemy.orm import sessionmaker | 13 | from sqlalchemy.orm import sessionmaker |
| 13 | import networkx as nx | 14 | import networkx as nx |
| 14 | 15 | ||
| 15 | # this project | 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 | # setup logger for this module | 22 | # setup logger for this module |
| 22 | logger = logging.getLogger(__name__) | 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 | class LearnException(Exception): | 27 | class LearnException(Exception): |
| 44 | pass | 28 | pass |
| 45 | 29 | ||
| @@ -86,38 +70,74 @@ class LearnApp(object): | @@ -86,38 +70,74 @@ class LearnApp(object): | ||
| 86 | 70 | ||
| 87 | # ------------------------------------------------------------------------ | 71 | # ------------------------------------------------------------------------ |
| 88 | def sanity_check_questions(self): | 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 | # login | 114 | # login |
| 104 | # ------------------------------------------------------------------------ | 115 | # ------------------------------------------------------------------------ |
| 105 | - async def login(self, uid, try_pw): | 116 | + async def login(self, uid, pw): |
| 106 | with self.db_session() as s: | 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 | if pw_ok: | 137 | if pw_ok: |
| 118 | if uid in self.online: | 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 | else: | 141 | else: |
| 122 | logger.info(f'User "{uid}" logged in') | 142 | logger.info(f'User "{uid}" logged in') |
| 123 | counter = 0 | 143 | counter = 0 |
| @@ -158,7 +178,9 @@ class LearnApp(object): | @@ -158,7 +178,9 @@ class LearnApp(object): | ||
| 158 | if not pw: | 178 | if not pw: |
| 159 | return False | 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 | with self.db_session() as s: | 185 | with self.db_session() as s: |
| 164 | u = s.query(Student).get(uid) | 186 | u = s.query(Student).get(uid) |
aprendizations/models.py
| 1 | 1 | ||
| 2 | +# third party libraries | ||
| 2 | from sqlalchemy import Column, ForeignKey, Integer, Float, String | 3 | from sqlalchemy import Column, ForeignKey, Integer, Float, String |
| 3 | from sqlalchemy.ext.declarative import declarative_base | 4 | from sqlalchemy.ext.declarative import declarative_base |
| 4 | from sqlalchemy.orm import relationship | 5 | from sqlalchemy.orm import relationship |
aprendizations/questions.py
| @@ -7,7 +7,7 @@ import logging | @@ -7,7 +7,7 @@ import logging | ||
| 7 | import asyncio | 7 | import asyncio |
| 8 | 8 | ||
| 9 | # this project | 9 | # this project |
| 10 | -from aprendizations.tools import run_script | 10 | +from .tools import run_script |
| 11 | 11 | ||
| 12 | # setup logger for this module | 12 | # setup logger for this module |
| 13 | logger = logging.getLogger(__name__) | 13 | logger = logging.getLogger(__name__) |
aprendizations/redirect.py
| 1 | #!/usr/bin/env python3 | 1 | #!/usr/bin/env python3 |
| 2 | 2 | ||
| 3 | +# python standard libraries | ||
| 4 | +import argparse | ||
| 5 | + | ||
| 6 | +# third party libraries | ||
| 3 | from tornado.web import RedirectHandler, Application | 7 | from tornado.web import RedirectHandler, Application |
| 4 | from tornado.ioloop import IOLoop | 8 | from tornado.ioloop import IOLoop |
| 5 | -import argparse | ||
| 6 | 9 | ||
| 7 | 10 | ||
| 8 | def main(): | 11 | def main(): |
aprendizations/serve.py
| @@ -14,16 +14,16 @@ import functools | @@ -14,16 +14,16 @@ import functools | ||
| 14 | import ssl | 14 | import ssl |
| 15 | import asyncio | 15 | import asyncio |
| 16 | 16 | ||
| 17 | -# user installed libraries | 17 | +# third party libraries |
| 18 | import tornado.ioloop | 18 | import tornado.ioloop |
| 19 | import tornado.web | 19 | import tornado.web |
| 20 | import tornado.httpserver | 20 | import tornado.httpserver |
| 21 | from tornado.escape import to_unicode | 21 | from tornado.escape import to_unicode |
| 22 | 22 | ||
| 23 | # this project | 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,6 +97,7 @@ class RankingsHandler(BaseHandler): | ||
| 97 | uid = self.current_user | 97 | uid = self.current_user |
| 98 | rankings = self.learn.get_rankings(uid) | 98 | rankings = self.learn.get_rankings(uid) |
| 99 | self.render('rankings.html', | 99 | self.render('rankings.html', |
| 100 | + appname=APP_NAME, | ||
| 100 | uid=uid, | 101 | uid=uid, |
| 101 | name=self.learn.get_student_name(uid), | 102 | name=self.learn.get_student_name(uid), |
| 102 | rankings=rankings) | 103 | rankings=rankings) |
| @@ -107,7 +108,9 @@ class RankingsHandler(BaseHandler): | @@ -107,7 +108,9 @@ class RankingsHandler(BaseHandler): | ||
| 107 | # ---------------------------------------------------------------------------- | 108 | # ---------------------------------------------------------------------------- |
| 108 | class LoginHandler(BaseHandler): | 109 | class LoginHandler(BaseHandler): |
| 109 | def get(self): | 110 | def get(self): |
| 110 | - self.render('login.html', error='') | 111 | + self.render('login.html', |
| 112 | + appname=APP_NAME, | ||
| 113 | + error='') | ||
| 111 | 114 | ||
| 112 | async def post(self): | 115 | async def post(self): |
| 113 | uid = self.get_body_argument('uid').lstrip('l') | 116 | uid = self.get_body_argument('uid').lstrip('l') |
| @@ -121,7 +124,9 @@ class LoginHandler(BaseHandler): | @@ -121,7 +124,9 @@ class LoginHandler(BaseHandler): | ||
| 121 | self.set_secure_cookie('counter', counter) | 124 | self.set_secure_cookie('counter', counter) |
| 122 | self.redirect('/') | 125 | self.redirect('/') |
| 123 | else: | 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,6 +173,7 @@ class RootHandler(BaseHandler): | ||
| 168 | def get(self): | 173 | def get(self): |
| 169 | uid = self.current_user | 174 | uid = self.current_user |
| 170 | self.render('maintopics-table.html', | 175 | self.render('maintopics-table.html', |
| 176 | + appname=APP_NAME, | ||
| 171 | uid=uid, | 177 | uid=uid, |
| 172 | name=self.learn.get_student_name(uid), | 178 | name=self.learn.get_student_name(uid), |
| 173 | state=self.learn.get_student_state(uid), | 179 | state=self.learn.get_student_state(uid), |
| @@ -193,6 +199,7 @@ class TopicHandler(BaseHandler): | @@ -193,6 +199,7 @@ class TopicHandler(BaseHandler): | ||
| 193 | self.redirect('/') | 199 | self.redirect('/') |
| 194 | else: | 200 | else: |
| 195 | self.render('topic.html', | 201 | self.render('topic.html', |
| 202 | + appname=APP_NAME, | ||
| 196 | uid=uid, | 203 | uid=uid, |
| 197 | name=self.learn.get_student_name(uid), | 204 | name=self.learn.get_student_name(uid), |
| 198 | ) | 205 | ) |
| @@ -201,9 +208,6 @@ class TopicHandler(BaseHandler): | @@ -201,9 +208,6 @@ class TopicHandler(BaseHandler): | ||
| 201 | # ---------------------------------------------------------------------------- | 208 | # ---------------------------------------------------------------------------- |
| 202 | # Serves files from the /public subdir of the topics. | 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 | class FileHandler(BaseHandler): | 211 | class FileHandler(BaseHandler): |
| 208 | SUPPORTED_METHODS = ['GET'] | 212 | SUPPORTED_METHODS = ['GET'] |
| 209 | 213 |
aprendizations/templates/login.html
aprendizations/templates/maintopics-table.html
| @@ -3,7 +3,7 @@ | @@ -3,7 +3,7 @@ | ||
| 3 | <!doctype html> | 3 | <!doctype html> |
| 4 | <html lang="pt-PT"> | 4 | <html lang="pt-PT"> |
| 5 | <head> | 5 | <head> |
| 6 | - <title>iLearn</title> | 6 | + <title>{{appname}}</title> |
| 7 | <link rel="icon" href="/static/favicon.ico"> | 7 | <link rel="icon" href="/static/favicon.ico"> |
| 8 | 8 | ||
| 9 | <meta charset="utf-8"> | 9 | <meta charset="utf-8"> |
aprendizations/templates/rankings.html
| @@ -3,7 +3,7 @@ | @@ -3,7 +3,7 @@ | ||
| 3 | <!doctype html> | 3 | <!doctype html> |
| 4 | <html lang="pt-PT"> | 4 | <html lang="pt-PT"> |
| 5 | <head> | 5 | <head> |
| 6 | - <title>iLearn</title> | 6 | + <title>{{appname}}</title> |
| 7 | <link rel="icon" href="/static/favicon.ico"> | 7 | <link rel="icon" href="/static/favicon.ico"> |
| 8 | 8 | ||
| 9 | <meta charset="utf-8"> | 9 | <meta charset="utf-8"> |
aprendizations/templates/topic.html
aprendizations/tools.py
| @@ -5,7 +5,7 @@ import subprocess | @@ -5,7 +5,7 @@ import subprocess | ||
| 5 | import logging | 5 | import logging |
| 6 | import re | 6 | import re |
| 7 | 7 | ||
| 8 | -# user installed libraries | 8 | +# third party libraries |
| 9 | import yaml | 9 | import yaml |
| 10 | import mistune | 10 | import mistune |
| 11 | from pygments import highlight | 11 | from pygments import highlight |