# coding=utf-8 import uuid import threading # multiprocessing.managers.BaseManager uses threading to serve incoming requests import hashlib import base64 import random from . import prolog_session from . import python_session from . import problems import db from errors.session import NoSuchSession, AuthenticationFailed import psycopg2.extras __all__ = ['get_session_by_id', 'get_or_create_session', 'UserSession'] sessions = {} # maps session IDs to session objects module_access_lock = threading.Lock() # use this lock to access the sessions dictionary class UserSession(object): """Abstracts a user session. Only public methods are available to the outside world due to the use of multiprocessing managers. Therefore prefix any private methods with an underscore (_). No properties are accessible; use getters and setters instead. Values are passed by value instead of by reference (deep copy!). """ def __init__(self, uid, username): self._access_lock = threading.Lock() self.sid = uuid.uuid4().hex self.uid = uid self.username = username self.prolog_session = None self.python_session = None def destroy(self): """Destroys the session.""" with self._access_lock: with module_access_lock: del sessions[self.sid] if self.prolog_session is not None: self.prolog_session.end() self.prolog_session = None if self.python_session is not None: self.python_session.end() self.python_session = None # TODO: add any cleanups as features are added! def get_sid(self): return self.sid def get_uid(self): return self.uid def get_prolog(self): with self._access_lock: if self.prolog_session is None: self.prolog_session = prolog_session.PrologSession() # lazy init return self.prolog_session def get_python(self): with self._access_lock: if self.python_session is None: self.python_session = python_session.PythonSession() # lazy init return self.python_session def get_problem_data(self, language, problem_group, problem): mod = problems.load_problem(language, problem_group, problem, 'en') mod_language = problems.load_language(language, 'en') # Get generic and problem-specific hints. hint = dict(mod_language.hint) hint.update(mod.hint) conn = db.get_connection() try: cur = conn.cursor() try: cur.execute("select l.id, l.name, g.id, g.name, p.id, p.name from problem p inner join language l on l.id = p.language_id inner join problem_group g on g.id = p.problem_group_id where l.identifier = %s and g.identifier = %s and p.identifier = %s", (language, problem_group, problem)) row = cur.fetchone() problem_id = row[4] result = { 'language': {'id': row[0], 'identifier': language, 'name': row[1]}, 'problem_group': {'id': row[2], 'identifier': problem_group, 'name': row[3]}, 'problem': {'id': problem_id, 'identifier': problem, 'name': row[5], 'slug': mod.slug, 'description': mod.description, 'hint': hint} } cur.execute("select content from solution where problem_id = %s and codeq_user_id = %s", (problem_id, self.uid)) row = cur.fetchone() if row: result['solution'] = row[0] else: result['solution'] = '' return result finally: cur.close() finally: conn.commit() db.return_connection(conn) def update_solution(self, problem_id, trace, solution): if (trace is None) and (solution is None): return conn = db.get_connection() try: cur = conn.cursor() try: # TODO: convert to upsert with postgresql 9.5 to eliminate the small window where it's possible for more than one concurrent insert to execute cur.execute('select id, trace, content from solution where codeq_user_id = %s and problem_id = %s for update', (self.uid, problem_id)) row = cur.fetchone() if row: if row[1]: new_trace = row[1] if trace: new_trace.extend(trace) else: new_trace = trace new_solution = row[2] if solution is None else solution cur.execute('update solution set content = %s, trace = %s where id = %s', (new_solution, psycopg2.extras.Json(new_trace), row[0])) else: # this is the first entry cur.execute('insert into solution (done, content, problem_id, codeq_user_id, trace) values (%s, %s, %s, %s, %s)', (False, solution, problem_id, self.uid, psycopg2.extras.Json(trace))) finally: cur.close() conn.commit() except: conn.rollback() raise finally: db.return_connection(conn) def __del__(self): # no locking needed if GC is removing us, as there cannot be any concurrent access by definition if hasattr(self, 'prolog_session') and (self.prolog_session is not None): self.prolog_session.end() self.prolog_session = None # TODO: add any cleanups as features are added! def get_session_by_id(sid): with module_access_lock: s = sessions.get(sid, None) if s is None: raise NoSuchSession('There is no session with SID {}'.format(sid)) return s def get_or_create_session(uid, username, sid=None): with module_access_lock: if sid is not None: s = sessions.get(sid) if s is not None: return s s = UserSession(uid, username) sessions[s.sid] = s return s def authenticate_and_create_session(username, password): conn = db.get_connection() try: cur = conn.cursor() try: cur.execute('select id, password from codeq_user where username = %s', (username,)) row = cur.fetchone() if row is None: raise AuthenticationFailed('No such user: {}'.format(username)) if verify_password(password, row[1]): return get_or_create_session(row[0], username) raise AuthenticationFailed('Password mismatch') finally: cur.close() finally: conn.commit() db.return_connection(conn) def verify_password(plain_password, encrypted_password): elts = encrypted_password.split('$') if len(elts) != 4: return False if elts[0] != 'pbkdf2_sha256': return False try: rounds = int(elts[1]) except: return False enc = hashlib.pbkdf2_hmac('sha256', plain_password.encode('utf-8'), elts[2].encode('utf-8'), rounds) return base64.b64encode(enc).decode('utf-8') == elts[3] _salt_chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' _salt_chars_len = len(_salt_chars) def encrypt_password(plain_password): rounds = 20000 chosen_chars = [] for i in range(0, 12): chosen_chars.append(_salt_chars[random.randrange(0, _salt_chars_len)]) salt = ''.join(chosen_chars) enc = hashlib.pbkdf2_hmac('sha256', plain_password.encode('utf-8'), salt.encode('utf-8'), rounds) return '{0}${1}${2}${3}'.format('pbkdf2_sha256', rounds, salt, base64.b64encode(enc).decode('utf-8')) random.seed() # TODO: add a session timeout timer