Repository: hedyorg/hedy

Issue:
Not showing For teachers, My profile and My programs tabs [BUG]
**Describe the bug**
When I go to hedycode.com I only see only 4 tabs: Home, Hedy, Learn More and Log in and not the three tabs (For teachers, My profile and My programs) that I need. When I go to Log in I am already logged in. Only when I logout and log back in again the 3 tabs (For teachers, My profile and My programs) appear. Every time I reopen my browser I have to do this again. I tried deleting my cookies but it did not help.

**Paste the Hedy code & level**
NA

**Expected behavior**
It would be nice if I can stay logged in and see all relevant tabs every time I go to hedycode.com 

**What machine and browser you were using (optional)**
I use Windows 10 with Firefox. I got the same problem when using Edge and Chrome.


Discussion:
I don't know exactly but I have a good suspicion what's causing this. Will have a thorough look in a couple of days. Thanks for reporting!

Resolving patch:
diff --git a/app.py b/app.py
--- a/app.py
+++ b/app.py
@@ -19,7 +19,7 @@
 from flask_commonmark import Commonmark
 from werkzeug.urls import url_encode
 from config import config
-from website.auth import auth_templates, current_user, requires_login, is_admin, is_teacher, update_is_teacher
+from website.auth import auth_templates, current_user, login_user_from_token_cookie, requires_login, is_admin, is_teacher, update_is_teacher
 from utils import timems, load_yaml_rt, dump_yaml_rt, version, is_debug_mode
 import utils
 import textwrap
@@ -169,11 +169,36 @@ def load_adventures_per_level(lang, level):
 
 cdn.Cdn(app, os.getenv('CDN_PREFIX'), os.getenv('HEROKU_SLUG_COMMIT', 'dev'))
 
-# Set session id if not already set. This must be done as one of the first things,
-# so the function should be defined high up.
 @app.before_request
-def set_session_cookie():
+def before_request_begin_logging():
+    """Initialize the query logging.
+
+    This needs to happen as one of the first things, as the database calls
+    etc. depend on it.
+    """
+    path = (str(request.path) + '?' + str(request.query_string)) if request.query_string else str(request.path)
+    querylog.begin_global_log_record(path=path, method=request.method)
+
+@app.after_request
+def after_request_log_status(response):
+    querylog.log_value(http_code=response.status_code)
+    return response
+
+
+@app.before_request
+def initialize_session():
+    """Make sure the session is initialized.
+
+    - Each session gets a unique session ID, so we can tell user sessions apart
+      and know what programs get submitted after each other.
+    - If the user has a cookie with a long-lived login token, log them in from
+      that cookie (copy the user info into the session for efficient access
+      later on).
+    """
+    # Invoke session_id() for its side effect
     session_id()
+    login_user_from_token_cookie()
+
 
 if os.getenv('IS_PRODUCTION'):
     @app.before_request
@@ -206,7 +231,9 @@ def before_request_https():
     app.config['SECRET_KEY'] = os.getenv('SECRET_KEY')
 
 else:
-    app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', uuid.uuid4().hex)
+    # The value doesn't matter for dev environments, but it needs to be a constant
+    # so that our cookies don't get invalidated every time we restart the server.
+    app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'WeAreDeveloping')
 
 if utils.is_heroku():
     app.config.update(
@@ -243,23 +270,12 @@ def setup_language():
 if utils.is_heroku() and not os.getenv('HEROKU_RELEASE_CREATED_AT'):
     logging.warning('Cannot determine release; enable Dyno metadata by running "heroku labs:enable runtime-dyno-metadata -a <APP_NAME>"')
 
-
-@app.before_request
-def before_request_begin_logging():
-    path = (str(request.path) + '?' + str(request.query_string)) if request.query_string else str(request.path)
-    querylog.begin_global_log_record(path=path, method=request.method)
-
 # A context processor injects variables in the context that are available to all templates.
 @app.context_processor
 def enrich_context_with_user_info():
     user = current_user()
     return dict(username=user.get('username', ''), is_teacher=is_teacher(user), is_admin=is_admin(user))
 
-@app.after_request
-def after_request_log_status(response):
-    querylog.log_value(http_code=response.status_code)
-    return response
-
 @app.after_request
 def set_security_headers(response):
     security_headers = {
@@ -529,8 +545,6 @@ def version_page():
         commit=commit)
 
 def programs_page(request):
-    print(session)
-    print(current_user())
     user = current_user()
     username = user['username']
     if not username:
diff --git a/website/auth.py b/website/auth.py
--- a/website/auth.py
+++ b/website/auth.py
@@ -15,8 +15,8 @@
 import requests
 from website import querylog, database
 
-cookie_name     = config['session']['cookie_name']
-session_length  = config['session']['session_length'] * 60
+TOKEN_COOKIE_NAME = config['session']['cookie_name']
+session_length    = config['session']['session_length'] * 60
 
 env = os.getenv('HEROKU_APP_NAME')
 
@@ -67,6 +67,10 @@ def current_user():
 
     return user
 
+def is_user_logged_in():
+    """Return whether or not a user is currently logged in."""
+    return bool(current_user()['username'])
+
 # Remove the current user from the Flask session.
 def forget_current_user():
     session.pop('user', None) # We are not interested in the value of the use key.
@@ -98,20 +102,27 @@ def update_is_teacher(user, is_teacher_value=1):
 def requires_login(f):
     @wraps(f)
     def inner(*args, **kws):
-        user = None
-        if request.cookies.get(cookie_name):
-            token = DATABASE.get_token(request.cookies.get(cookie_name))
-            if not token:
-                return 'unauthorized', 403
-            user = DATABASE.user_by_username(token['username'])
-            if not user:
-                return 'unauthorized', 403
-        else:
+        if not is_user_logged_in():
             return 'unauthorized', 403
-
-        return f(user, *args, **kws)
+        return f(current_user(), *args, **kws)
     return inner
 
+def login_user_from_token_cookie():
+    """Use the long-term token cookie in the user's request to try and look them up, if not already logged in."""
+    if is_user_logged_in():
+        return
+
+    if not request.cookies.get(TOKEN_COOKIE_NAME):
+        return
+
+    token = DATABASE.get_token(request.cookies.get(TOKEN_COOKIE_NAME))
+    if not token:
+        return
+
+    user = DATABASE.user_by_username(token['username'])
+    if user:
+        remember_current_user(user)
+
 # Note: translations are used only for texts that will be seen by a GUI user.
 def routes(app, database):
     global DATABASE
@@ -154,7 +165,7 @@ def login():
 
         # 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=is_heroku(), samesite='Lax', path='/', max_age=365 * 24 * 60 * 60)
+        resp.set_cookie(TOKEN_COOKIE_NAME, value=cookie, httponly=True, secure=is_heroku(), samesite='Lax', path='/', max_age=365 * 24 * 60 * 60)
 
         # Remember the current user on the session. This is "new style" logins, which should ultimately
         # replace "old style" logins (with the cookie above), as it requires fewer database calls.
@@ -273,7 +284,7 @@ def signup():
 
         # 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=is_heroku(), samesite='Lax', path='/', max_age=365 * 24 * 60 * 60)
+        resp.set_cookie(TOKEN_COOKIE_NAME, value=cookie, httponly=True, secure=is_heroku(), samesite='Lax', path='/', max_age=365 * 24 * 60 * 60)
         remember_current_user(user)
 
         return resp
@@ -305,15 +316,15 @@ def verify_email():
     @app.route('/auth/logout', methods=['POST'])
     def logout():
         forget_current_user()
-        if request.cookies.get(cookie_name):
-            DATABASE.forget_token(request.cookies.get(cookie_name))
+        if request.cookies.get(TOKEN_COOKIE_NAME):
+            DATABASE.forget_token(request.cookies.get(TOKEN_COOKIE_NAME))
         return '', 200
 
     @app.route('/auth/destroy', methods=['POST'])
     @requires_login
     def destroy(user):
         forget_current_user()
-        DATABASE.forget_token(request.cookies.get(cookie_name))
+        DATABASE.forget_token(request.cookies.get(TOKEN_COOKIE_NAME))
         DATABASE.forget_user(user['username'])
         return '', 200
 
@@ -332,6 +343,9 @@ def change_password(user):
         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
 
@@ -408,6 +422,9 @@ def update_profile(user):
     @app.route('/profile', methods=['GET'])
     @requires_login
     def get_profile(user):
+        # The user object we got from 'requires_login' is not fully hydrated yet. Look up the database user.
+        user = DATABASE.user_by_username(user['username'])
+
         output = {'username': user['username'], 'email': user['email']}
         for field in['birth_year', 'country', 'gender', 'prog_experience', 'experience_languages']:
             if field in user:


Test patch: