def mark_as_teacher(): if not is_admin(request) and not is_testing_request(request): return 'unauthorized', 403 body = request.json # Validations if not isinstance(body, dict): return 'body must be an object', 400 if not isinstance(body.get('username'), str): return 'body.username must be a string', 400 if not isinstance(body.get('is_teacher'), bool): return 'body.is_teacher must be boolean', 400 user = DATABASE.user_by_username(body['username'].strip().lower()) if not user: return 'invalid username', 400 DATABASE.update_user(user['username'], {'is_teacher': 1 if body['is_teacher'] else 0}) if body['is_teacher'] and not is_testing_request(request): send_email_template('welcome_teacher', user['email'], '') return '', 200
def reset(): body = request.json # Validations if not isinstance(body, dict): return 'body must be an object', 400 if not isinstance(body.get('username'), str): return 'body.username must be a string', 400 if not isinstance(body.get('token'), str): return 'body.token must be a string', 400 if not isinstance(body.get('password'), str): return 'body.password be a string', 400 if len(body['password']) < 6: return 'password must be at least six characters long', 400 # There's no need to trim or lowercase username, because it should come within a link prepared by the app itself and not inputted manually by the user. token = DATABASE.get_token(body['username']) if not token: return 'invalid username/token', 403 if not check_password(body['token'], token['token']): return 'invalid username/token', 403 hashed = hash(body['password'], make_salt()) token = DATABASE.forget_token(body['username']) DATABASE.update_user(body['username'], {'password': hashed}) user = DATABASE.user_by_username(body['username']) if not is_testing_request(request): send_email_template('reset_password', user['email'], requested_lang(), None) return '', 200
def change_password(user): body = request.json if not isinstance(body, dict): return 'body must be an object', 400 if not isinstance(body.get('old_password'), str): return 'body.old_password must be a string', 400 if not isinstance(body.get('new_password'), str): return 'body.new_password must be a string', 400 if len(body['new_password']) < 6: return 'password must be at least six characters long', 400 # The user object we got from 'requires_login' doesn't have the password, so look that up in the database user = DATABASE.user_by_username(user['username']) if not check_password(body['old_password'], user['password']): return 'invalid username/password', 403 hashed = hash(body['new_password'], make_salt()) DATABASE.update_user(user['username'], {'password': hashed}) # We are not updating the user in the Flask session, because we should not rely on the password in anyway. if not is_testing_request(request): send_email_template('change_password', user['email'], None) return '', 200
def reset(): body = request.json # Validations if not type_check(body, 'dict'): return 'body must be an object', 400 if not object_check(body, 'username', 'str'): return 'body.username must be a string', 400 if not object_check(body, 'token', 'str'): return 'body.token must be a string', 400 if not object_check(body, 'password', 'str'): return 'body.password be a string', 400 if len(body['password']) < 6: return 'password must be at least six characters long', 400 # There's no need to trim or lowercase username, because it should come within a link prepared by the app itself and not inputted manually by the user. token = db_get('tokens', {'id': body['username']}) if not token: return 'invalid username/token', 403 if not check_password(body['token'], token['token']): return 'invalid username/token', 403 hashed = hash(body['password'], make_salt()) token = db_del('tokens', {'id': body['username']}) db_update('users', {'username': body['username'], 'password': hashed}) user = db_get('users', {'username': body['username']}) if not is_testing_request(request): send_email_template('reset_password', user['email'], requested_lang(), None) return '', 200
def echo_session_vars_main(): if not utils.is_testing_request(request): return 'This endpoint is only meant for E2E tests', 400 return jsonify({ 'session': dict(session), 'proxy_enabled': bool(os.getenv('PROXY_TO_TEST_HOST')) })
def auth_templates(page, lang, menu, request): if page == 'my-profile': return render_template('profile.html', lang=lang, auth=TRANSLATIONS.get_translations( lang, 'Auth'), menu=menu, username=current_user(request)['username'], is_teacher=is_teacher(request), current_page='my-profile') if page in ['signup', 'login', 'recover', 'reset']: return render_template(page + '.html', lang=lang, auth=TRANSLATIONS.get_translations( lang, 'Auth'), menu=menu, username=current_user(request)['username'], is_teacher=False, current_page='login') if page == 'admin': if not is_testing_request(request) and not is_admin(request): return 'unauthorized', 403 # After hitting 1k users, it'd be wise to add pagination. users = DATABASE.all_users() userdata = [] fields = [ 'username', 'email', 'birth_year', 'country', 'gender', 'created', 'last_login', 'verification_pending', 'is_teacher', 'program_count', 'prog_experience', 'experience_languages' ] for user in users: data = {} for field in fields: if field in user: data[field] = user[field] else: data[field] = None data['email_verified'] = not bool(data['verification_pending']) data['is_teacher'] = bool(data['is_teacher']) data['created'] = mstoisostring(data['created']) if data['last_login']: data['last_login'] = mstoisostring(data['last_login']) userdata.append(data) userdata.sort(key=lambda user: user['created'], reverse=True) counter = 1 for user in userdata: user['index'] = counter counter = counter + 1 return render_template('admin.html', lang=lang, users=userdata, program_count=DATABASE.all_programs_count(), user_count=DATABASE.all_users_count(), auth=TRANSLATIONS.get_translations( lang, 'Auth'))
def update_is_teacher(user, is_teacher_value=1): user_is_teacher = 'is_teacher' in user and user['is_teacher'] user_becomes_teacher = is_teacher_value and not user_is_teacher DATABASE.update_user(user['username'], {'is_teacher': is_teacher_value}) if user_becomes_teacher and not is_testing_request(request): send_email_template('welcome_teacher', user['email'], '')
def get_class(user, class_id): if not is_teacher(request): return 'Only teachers can retrieve classes', 403 Class = DATABASE.get_class(class_id) if not Class or Class['teacher'] != user['username']: return 'No such class', 404 students = [] for student_username in Class.get('students', []): student = DATABASE.user_by_username(student_username) programs = DATABASE.programs_for_user(student_username) highest_level = max( program['level'] for program in programs) if len(programs) else 0 sorted_public_programs = list( sorted( [program for program in programs if program.get('public')], key=lambda p: p['date'])) if sorted_public_programs: latest_shared = sorted_public_programs[-1] latest_shared['link'] = os.getenv( 'BASE_URL') + f"/hedy/{latest_shared['id']}/view" else: latest_shared = None students.append({ 'username': student_username, 'last_login': utils.mstoisostring(student['last_login']), 'programs': len(programs), 'highest_level': highest_level, 'latest_shared': latest_shared }) if utils.is_testing_request(request): return jsonify({ 'students': students, 'link': Class['link'], 'name': Class['name'], 'id': Class['id'] }) return render_template( 'class-overview.html', lang=requested_lang(), auth=TRANSLATIONS.get_translations(requested_lang(), 'Auth'), menu=render_main_menu('my-profile'), username=current_user(request)['username'], is_teacher=is_teacher(request), current_page='my-profile', class_info={ 'students': students, 'link': os.getenv('BASE_URL') + '/hedy/l/' + Class['link'], 'name': Class['name'], 'id': Class['id'] })
def redirect_ab (request): # If this is a testing request, we return True if utils.is_testing_request (request): return True # If the user is logged in, we use their username as identifier, otherwise we use the session id user_identifier = current_user(request) ['username'] or str (session['session_id']) # This will send either % PROXY_TO_TEST_PROPORTION of the requests into redirect, or 50% if that variable is not specified. redirect_proportion = int (os.getenv ('PROXY_TO_TEST_PROPORTION', '50')) redirect_flag = (hash_user_or_session (user_identifier) % 100) < redirect_proportion return redirect_flag
def get_class(user, class_id): app.logger.info('This is info output') if not is_teacher(user): return utils.page_403(ui_message='retrieve_class') Class = DATABASE.get_class(class_id) if not Class or Class['teacher'] != user['username']: return utils.page_404(ui_message='no_such_class') students = [] for student_username in Class.get('students', []): student = DATABASE.user_by_username(student_username) programs = DATABASE.programs_for_user(student_username) highest_level = max( program['level'] for program in programs) if len(programs) else 0 sorted_public_programs = list( sorted( [program for program in programs if program.get('public')], key=lambda p: p['date'])) if sorted_public_programs: latest_shared = sorted_public_programs[-1] latest_shared['link'] = f"/hedy/{latest_shared['id']}/view" else: latest_shared = None students.append({ 'username': student_username, 'last_login': utils.datetotimeordate( utils.mstoisostring(student['last_login'])), 'programs': len(programs), 'highest_level': highest_level, 'latest_shared': latest_shared }) if utils.is_testing_request(request): return jsonify({ 'students': students, 'link': Class['link'], 'name': Class['name'], 'id': Class['id'] }) return render_template( 'class-overview.html', current_page='for-teachers', page_title=hedyweb.get_page_title('class overview'), class_info={ 'students': students, 'link': '/hedy/l/' + Class['link'], 'name': Class['name'], 'id': Class['id'] })
def auth_templates(page, page_title, lang, request): if page == 'my-profile': return render_template('profile.html', page_title=page_title, current_page='my-profile') if page in ['signup', 'login', 'recover', 'reset']: return render_template(page + '.html', page_title=page_title, is_teacher=False, current_page='login') if page == 'admin': if not is_testing_request(request) and not is_admin(current_user()): return 'unauthorized', 403 # After hitting 1k users, it'd be wise to add pagination. users = DATABASE.all_users() userdata = [] fields = [ 'username', 'email', 'birth_year', 'country', 'gender', 'created', 'last_login', 'verification_pending', 'is_teacher', 'program_count', 'prog_experience', 'experience_languages' ] for user in users: data = pick(user, *fields) data['email_verified'] = not bool(data['verification_pending']) data['is_teacher'] = bool(data['is_teacher']) data['created'] = mstoisostring( data['created']) if data['created'] else '?' if data['last_login']: data['last_login'] = mstoisostring( data['last_login']) if data['last_login'] else '?' userdata.append(data) userdata.sort(key=lambda user: user['created'], reverse=True) counter = 1 for user in userdata: user['index'] = counter counter = counter + 1 return render_template('admin.html', users=userdata, page_title=page_title, program_count=DATABASE.all_programs_count(), user_count=DATABASE.all_users_count())
def change_user_email(): if not is_admin(request): return 'unauthorized', 403 body = request.json # Validations if not type_check(body, 'dict'): return 'body must be an object', 400 if not object_check(body, 'username', 'str'): return 'body.username must be a string', 400 if not object_check(body, 'email', 'str'): return 'body.email must be a string', 400 if not valid_email(body['email']): return 'email must be a valid email', 400 user = db_get('users', {'username': body['username'].strip().lower()}) if not user: return 'invalid username', 400 token = make_salt() hashed_token = hash(token, make_salt()) # We assume that this email is not in use by any other users. In other words, we trust the admin to enter a valid, not yet used email address. db_update( 'users', { 'username': user['username'], 'email': body['email'], 'verification_pending': hashed_token }) # If this is an e2e test, we return the email verification token directly instead of emailing it. if is_testing_request(request): resp = {'username': user['username'], 'token': hashed_token} else: send_email_template( 'welcome_verify', body['email'], requested_lang(), os.getenv('BASE_URL') + '/auth/verify?username='******'username']) + '&token=' + urllib.parse.quote_plus(hashed_token)) return '', 200
def change_user_email(): user = current_user() if not is_admin(user): return 'unauthorized', 403 body = request.json # Validations if not isinstance(body, dict): return 'body must be an object', 400 if not isinstance(body.get('username'), str): return 'body.username must be a string', 400 if not isinstance(body.get('email'), str): return 'body.email must be a string', 400 if not valid_email(body['email']): return 'email must be a valid email', 400 user = DATABASE.user_by_username(body['username'].strip().lower()) if not user: return 'invalid username', 400 token = make_salt() hashed_token = hash(token, make_salt()) # We assume that this email is not in use by any other users. In other words, we trust the admin to enter a valid, not yet used email address. DATABASE.update_user(user['username'], { 'email': body['email'], 'verification_pending': hashed_token }) # If this is an e2e test, we return the email verification token directly instead of emailing it. if is_testing_request(request): resp = {'username': user['username'], 'token': hashed_token} else: send_email_template( 'welcome_verify', body['email'], email_base_url() + '/auth/verify?username='******'username']) + '&token=' + urllib.parse.quote_plus(hashed_token)) return '', 200
def recover(): body = request.json # Validations if not type_check(body, 'dict'): return 'body must be an object', 400 if not object_check(body, 'username', 'str'): return 'body.username must be a string', 400 # If username has an @-sign, then it's an email if '@' in body['username']: user = db_get('users', {'email': body['username'].strip().lower()}, True) else: user = db_get('users', {'username': body['username'].strip().lower()}) if not user: return 'invalid username', 403 token = make_salt() hashed = hash(token, make_salt()) db_create( 'tokens', { 'id': user['username'], 'token': hashed, 'ttl': times() + session_length }) if is_testing_request(request): # If this is an e2e test, we return the email verification token directly instead of emailing it. return jsonify({'username': user['username'], 'token': token}), 200 else: send_email_template( 'recover_password', user['email'], requested_lang(), os.getenv('BASE_URL') + '/reset?username='******'username']) + '&token=' + urllib.parse.quote_plus(token)) return '', 200
def mark_as_teacher(): if not is_admin(request) and not is_testing_request(request): return 'unauthorized', 403 body = request.json # Validations if not isinstance(body, dict): return 'body must be an object', 400 if not isinstance(body.get('username'), str): return 'body.username must be a string', 400 if not isinstance(body.get('is_teacher'), bool): return 'body.is_teacher must be boolean', 400 user = DATABASE.user_by_username(body['username'].strip().lower()) if not user: return 'invalid username', 400 is_teacher_value = 1 if body['is_teacher'] else 0 update_is_teacher(user, is_teacher_value) return '', 200
def change_password(user): body = request.json if not isinstance(body, dict): return 'body must be an object', 400 if not isinstance(body.get('old_password'), str): return 'body.old_password must be a string', 400 if not isinstance(body.get('new_password'), str): return 'body.new_password must be a string', 400 if len(body['new_password']) < 6: return 'password must be at least six characters long', 400 if not check_password(body['old_password'], user['password']): return 'invalid username/password', 403 hashed = hash(body['new_password'], make_salt()) DATABASE.update_user(user['username'], {'password': hashed}) if not is_testing_request(request): send_email_template('change_password', user['email'], None) return '', 200
def change_password(user): body = request.json if not type_check(body, 'dict'): return 'body must be an object', 400 if not object_check(body, 'old_password', 'str'): return 'body.old_password must be a string', 400 if not object_check(body, 'new_password', 'str'): return 'body.new_password must be a string', 400 if len(body['new_password']) < 6: return 'password must be at least six characters long', 400 if not check_password(body['old_password'], user['password']): return 'invalid username/password', 403 hashed = hash(body['new_password'], make_salt()) db_update('users', {'username': user['username'], 'password': hashed}) if not is_testing_request(request): send_email_template('change_password', user['email'], requested_lang(), None) return '', 200
def recover(): body = request.json # Validations if not isinstance(body, dict): return 'body must be an object', 400 if not isinstance(body.get('username'), str): return 'body.username must be a string', 400 # If username has an @-sign, then it's an email if '@' in body['username']: user = DATABASE.user_by_email(body['username'].strip().lower()) else: user = DATABASE.user_by_username(body['username'].strip().lower()) if not user: return 'invalid username', 403 token = make_salt() hashed = hash(token, make_salt()) DATABASE.store_token({ 'id': user['username'], 'token': hashed, 'ttl': times() + session_length }) if is_testing_request(request): # If this is an e2e test, we return the email verification token directly instead of emailing it. return jsonify({'username': user['username'], 'token': token}), 200 else: send_email_template( 'recover_password', user['email'], os.getenv('BASE_URL') + '/reset?username='******'username']) + '&token=' + urllib.parse.quote_plus(token)) return '', 200
def signup(): body = request.json # Validations, mandatory fields if not type_check(body, 'dict'): return 'body must be an object', 400 if not object_check(body, 'username', 'str'): return 'username must be a string', 400 if '@' in body['username']: return 'username cannot contain an @-sign', 400 if ':' in body['username']: return 'username cannot contain a colon', 400 if len(body['username'].strip()) < 3: return 'username must be at least three characters long', 400 if not object_check(body, 'password', 'str'): return 'password must be a string', 400 if len(body['password']) < 6: return 'password must be at least six characters long', 400 if not object_check(body, 'email', 'str'): return 'email must be a string', 400 if not valid_email(body['email']): return 'email must be a valid email', 400 # Validations, optional fields if 'country' in body: if not body['country'] in countries: return 'country must be a valid country', 400 if 'birth_year' in body: if not object_check( body, 'birth_year', 'int') or body['birth_year'] <= 1900 or body[ 'birth_year'] > datetime.datetime.now().year: return 'birth_year must be a year between 1900 and ' + datetime.datetime.now( ).year, 400 if 'gender' in body: if body['gender'] != 'm' and body['gender'] != 'f' and body[ 'gender'] != 'o': return 'gender must be m/f/o', 400 user = db_get('users', {'username': body['username'].strip().lower()}) if user: return 'username exists', 403 email = db_get('users', {'email': body['email'].strip().lower()}, True) if email: return 'email exists', 403 hashed = hash(body['password'], make_salt()) token = make_salt() hashed_token = hash(token, make_salt()) username = body['username'].strip().lower() email = body['email'].strip().lower() if not is_testing_request( request) and 'subscribe' in body and body['subscribe'] == True: # If we have a Mailchimp API key, we use it to add the subscriber through the API if os.getenv('MAILCHIMP_API_KEY') and os.getenv( 'MAILCHIMP_AUDIENCE_ID'): # The first domain in the path is the server name, which is contained in the Mailchimp API key request_path = 'https://' + os.getenv( 'MAILCHIMP_API_KEY').split( '-')[1] + '.api.mailchimp.com/3.0/lists/' + os.getenv( 'MAILCHIMP_AUDIENCE_ID') + '/members' request_headers = { 'Content-Type': 'application/json', 'Authorization': 'apikey ' + os.getenv('MAILCHIMP_API_KEY') } request_body = {'email_address': email, 'status': 'subscribed'} r = requests.post(request_path, headers=request_headers, data=json.dumps(request_body)) subscription_error = None if r.status_code != 200 and r.status_code != 400: subscription_error = True # We can get a 400 if the email is already subscribed to the list. We should ignore this error. if r.status_code == 400 and not re.match( '.*already a list member', r.text): subscription_error = True # If there's an error in subscription through the API, we report it to the main email address if subscription_error: send_email( config['email']['sender'], 'ERROR - Subscription to Hedy newsletter on signup', email, '<p>' + email + '</p><pre>Status:' + str(r.status_code) + ' Body:' + r.text + '</pre>') # Otherwise, we send an email to notify about this to the main email address else: send_email(config['email']['sender'], 'Subscription to Hedy newsletter on signup', email, '<p>' + email + '</p>') user = { 'username': username, 'password': hashed, 'email': email, 'created': timems(), 'verification_pending': hashed_token, 'last_login': timems() } if 'country' in body: user['country'] = body['country'] if 'birth_year' in body: user['birth_year'] = body['birth_year'] if 'gender' in body: user['gender'] = body['gender'] db_create('users', user) # We automatically login the user cookie = make_salt() db_create( 'tokens', { 'id': cookie, 'username': user['username'], 'ttl': times() + session_length }) # If this is an e2e test, we return the email verification token directly instead of emailing it. if is_testing_request(request): resp = make_response({'username': username, 'token': hashed_token}) # Otherwise, we send an email with a verification link and we return an empty body else: send_email_template( 'welcome_verify', email, requested_lang(), os.getenv('BASE_URL') + '/auth/verify?username='******'&token=' + urllib.parse.quote_plus(hashed_token)) resp = make_response({}) # We set the cookie to expire in a year, just so that the browser won't invalidate it if the same cookie gets renewed by constant use. # The server will decide whether the cookie expires. resp.set_cookie(cookie_name, value=cookie, httponly=True, secure=True, samesite='Lax', path='/', max_age=365 * 24 * 60 * 60) return resp
def echo_session_vars_test(): if not utils.is_testing_request(request): return 'This endpoint is only meant for E2E tests', 400 return jsonify({'session': dict(session)})
def before_request_proxy_testing(): if utils.is_testing_request(request): if os.getenv('IS_TEST_ENV'): session['test_session'] = 'test'
def reject_e2e_requests(): if utils.is_testing_request(request): return 'No E2E tests are allowed in production', 400
def update_profile(user): body = request.json if not type_check(body, 'dict'): return 'body must be an object', 400 if 'email' in body: if not object_check(body, 'email', 'str'): return 'body.email must be a string', 400 if not valid_email(body['email']): return 'body.email must be a valid email', 400 if 'country' in body: if not body['country'] in countries: return 'body.country must be a valid country', 400 if 'birth_year' in body: if not object_check( body, 'birth_year', 'int') or body['birth_year'] <= 1900 or body[ 'birth_year'] > datetime.datetime.now().year: return 'birth_year must be a year between 1900 and ' + str( datetime.datetime.now().year), 400 if 'gender' in body: if body['gender'] != 'm' and body['gender'] != 'f' and body[ 'gender'] != 'o': return 'body.gender must be m/f/o', 400 resp = {} if 'email' in body: email = body['email'].strip().lower() if email != user['email']: exists = db_get('users', {'email': email}, True) if exists: return 'email exists', 403 token = make_salt() hashed_token = hash(token, make_salt()) db_update( 'users', { 'username': user['username'], 'email': email, 'verification_pending': hashed_token }) # If this is an e2e test, we return the email verification token directly instead of emailing it. if is_testing_request(request): resp = { 'username': user['username'], 'token': hashed_token } else: send_email_template( 'welcome_verify', email, requested_lang(), os.getenv('BASE_URL') + '/auth/verify?username='******'username']) + '&token=' + urllib.parse.quote_plus(hashed_token)) if 'country' in body: db_update('users', { 'username': user['username'], 'country': body['country'] }) if 'birth_year' in body: db_update('users', { 'username': user['username'], 'birth_year': body['birth_year'] }) if 'gender' in body: db_update('users', { 'username': user['username'], 'gender': body['gender'] }) return jsonify(resp)
def update_profile(user): body = request.json if not isinstance(body, dict): return 'body must be an object', 400 if 'email' in body: if not isinstance(body.get('email'), str): return 'body.email must be a string', 400 if not valid_email(body['email']): return 'body.email must be a valid email', 400 if 'country' in body: if not body['country'] in countries: return 'body.country must be a valid country', 400 if 'birth_year' in body: if not isinstance(body.get('birth_year'), int) or body['birth_year'] <= 1900 or body[ 'birth_year'] > datetime.datetime.now().year: return 'birth_year must be a year between 1900 and ' + str( datetime.datetime.now().year), 400 if 'gender' in body: if body['gender'] != 'm' and body['gender'] != 'f' and body[ 'gender'] != 'o': return 'body.gender must be m/f/o', 400 if 'prog_experience' in body and body['prog_experience'] not in [ 'yes', 'no' ]: return 'If present, prog_experience must be "yes" or "no"', 400 if 'experience_languages' in body: if not isinstance(body['experience_languages'], list): return 'If present, experience_languages must be an array', 400 for language in body['experience_languages']: if language not in [ 'scratch', 'other_block', 'python', 'other_text' ]: return 'Invalid language: ' + str(language), 400 resp = {} if 'email' in body: email = body['email'].strip().lower() if email != user['email']: exists = DATABASE.user_by_email(email) if exists: return 'email exists', 403 token = make_salt() hashed_token = hash(token, make_salt()) DATABASE.update_user(user['username'], { 'email': email, 'verification_pending': hashed_token }) # If this is an e2e test, we return the email verification token directly instead of emailing it. if is_testing_request(request): resp = { 'username': user['username'], 'token': hashed_token } else: send_email_template( 'welcome_verify', email, requested_lang(), os.getenv('BASE_URL') + '/auth/verify?username='******'username']) + '&token=' + urllib.parse.quote_plus(hashed_token)) username = user['username'] updates = {} for field in [ 'country', 'birth_year', 'gender', 'prog_experience', 'experience_languages' ]: if field in body: if field == 'experience_languages' and len(body[field]) == 0: updates[field] = None else: updates[field] = body[field] if updates: DATABASE.update_user(username, updates) return jsonify(resp)