From 99ac4455eb3a73eb001ba46affce89712b23bbad Mon Sep 17 00:00:00 2001
From: Siebe Vlietinck <71773032+Vucis@users.noreply.github.com>
Date: Thu, 18 Apr 2024 21:43:59 +0200
Subject: [PATCH] Full authentication (#216)
* backend-authentication
* fix projects
* remove extra requirements
* fix test-auth_server
* added: frontend redirect link
* fix backend auth
* fix error + code_challenge in frontend link
* line too long + frontend linter errors
* actual linter fix
* removed code_verifier until milestone 3
* added ability to log out and added everything to init
* redis added later
* added login button to header
* linter fix unused import
* add display_name to user creation
* test tests
* linter fix
* cookies test
* flask test cookies?
* added logout button
* with client
---
backend/Dockerfile_auth_test | 2 +-
...ments.txt => auth_server_requirements.txt} | 0
backend/project/__init__.py | 26 +++-
.../project/endpoints/authentication/auth.py | 125 ++++++++++++++++++
.../endpoints/authentication/logout.py | 28 ++++
.../project/endpoints/authentication/me.py | 33 +++++
.../project/endpoints/projects/projects.py | 2 +-
backend/project/init_auth.py | 56 ++++++++
backend/project/utils/authentication.py | 71 ++--------
backend/requirements.txt | 1 +
backend/test_auth_server/__main__.py | 1 -
backend/tests.yaml | 2 +
backend/tests/endpoints/project_test.py | 24 ++--
frontend/src/components/Header/Header.tsx | 6 +-
frontend/src/components/Header/Login.tsx | 16 +++
frontend/src/components/Header/Logout.tsx | 14 ++
16 files changed, 328 insertions(+), 79 deletions(-)
rename backend/{auth_requirements.txt => auth_server_requirements.txt} (100%)
create mode 100644 backend/project/endpoints/authentication/auth.py
create mode 100644 backend/project/endpoints/authentication/logout.py
create mode 100644 backend/project/endpoints/authentication/me.py
create mode 100644 backend/project/init_auth.py
create mode 100644 frontend/src/components/Header/Login.tsx
create mode 100644 frontend/src/components/Header/Logout.tsx
diff --git a/backend/Dockerfile_auth_test b/backend/Dockerfile_auth_test
index d7541b0d..3653f2b8 100644
--- a/backend/Dockerfile_auth_test
+++ b/backend/Dockerfile_auth_test
@@ -2,7 +2,7 @@ FROM python:3.9
RUN mkdir /auth-app
WORKDIR /auth-app
ADD ./test_auth_server /auth-app/
-COPY auth_requirements.txt /auth-app/requirements.txt
+COPY auth_server_requirements.txt /auth-app/requirements.txt
RUN pip3 install -r requirements.txt
COPY . /auth-app
ENTRYPOINT ["python"]
diff --git a/backend/auth_requirements.txt b/backend/auth_server_requirements.txt
similarity index 100%
rename from backend/auth_requirements.txt
rename to backend/auth_server_requirements.txt
diff --git a/backend/project/__init__.py b/backend/project/__init__.py
index fe9be2e4..434980df 100644
--- a/backend/project/__init__.py
+++ b/backend/project/__init__.py
@@ -1,8 +1,13 @@
""" Flask API base file
This file is the base of the Flask API. It contains the basic structure of the API.
"""
+from os import getenv
+from datetime import timedelta
+
+from dotenv import load_dotenv
from flask import Flask
+from flask_jwt_extended import JWTManager
from flask_cors import CORS
from sqlalchemy_utils import register_composites
from .executor import executor
@@ -14,6 +19,13 @@
from .endpoints.submissions.submission_config import submissions_bp
from .endpoints.courses.join_codes.join_codes_config import join_codes_bp
from .endpoints.docs.docs_endpoint import swagger_ui_blueprint
+from .endpoints.authentication.auth import auth_bp
+from .endpoints.authentication.me import me_bp
+from .endpoints.authentication.logout import logout_bp
+from .init_auth import auth_init
+
+load_dotenv()
+JWT_SECRET_KEY = getenv("JWT_SECRET_KEY")
def create_app():
"""
@@ -23,6 +35,13 @@ def create_app():
"""
app = Flask(__name__)
+ app.config["JWT_COOKIE_SECURE"] = True
+ app.config["JWT_COOKIE_CSRF_PROTECT"] = True
+ app.config["JWT_TOKEN_LOCATION"] = ["cookies"]
+ app.config["JWT_SECRET_KEY"] = JWT_SECRET_KEY
+ app.config["JWT_ACCESS_TOKEN_EXPIRES"] = timedelta(hours=3)
+ app.config["JWT_ACCESS_COOKIE_NAME"] = "peristeronas_access_token"
+ app.config["JWT_SESSION_COOKIE"] = False
executor.init_app(app)
app.register_blueprint(index_bp)
app.register_blueprint(users_bp)
@@ -31,7 +50,12 @@ def create_app():
app.register_blueprint(submissions_bp)
app.register_blueprint(join_codes_bp)
app.register_blueprint(swagger_ui_blueprint)
+ app.register_blueprint(auth_bp)
+ app.register_blueprint(me_bp)
+ app.register_blueprint(logout_bp)
+ jwt = JWTManager(app)
+ auth_init(jwt, app)
return app
def create_app_with_db(db_uri: str):
@@ -52,5 +76,5 @@ def create_app_with_db(db_uri: str):
# Getting a connection from the scoped session
connection = db.session.connection()
register_composites(connection)
- CORS(app)
+ CORS(app, supports_credentials=True)
return app
diff --git a/backend/project/endpoints/authentication/auth.py b/backend/project/endpoints/authentication/auth.py
new file mode 100644
index 00000000..ab59ef5b
--- /dev/null
+++ b/backend/project/endpoints/authentication/auth.py
@@ -0,0 +1,125 @@
+"""Auth api endpoint"""
+from os import getenv
+
+from dotenv import load_dotenv
+import requests
+from flask import Blueprint, request, redirect, abort, make_response
+from flask_jwt_extended import create_access_token, set_access_cookies
+from flask_restful import Resource, Api
+from sqlalchemy.exc import SQLAlchemyError
+
+from project import db
+
+from project.models.user import User, Role
+
+auth_bp = Blueprint("auth", __name__)
+auth_api = Api(auth_bp)
+
+load_dotenv()
+API_URL = getenv("API_HOST")
+AUTH_METHOD = getenv("AUTH_METHOD")
+AUTHENTICATION_URL = getenv("AUTHENTICATION_URL")
+CLIENT_ID = getenv("CLIENT_ID")
+CLIENT_SECRET = getenv("CLIENT_SECRET")
+HOMEPAGE_URL = getenv("HOMEPAGE_URL")
+TENANT_ID = getenv("TENANT_ID")
+
+def microsoft_authentication():
+ """
+ This function will handle a microsoft based login,
+ creating a new user profile in the process and
+ return a valid access token as a cookie.
+ Redirects to the homepage of the website
+ """
+ code = request.args.get("code")
+ if code is None:
+ return {"message":"This endpoint is only used for authentication."}, 400
+ # got code from microsoft
+ data = {"client_id":CLIENT_ID,
+ "scope":".default",
+ "code":code,
+ "redirect_uri":f"{API_URL}/auth",
+ "grant_type":"authorization_code",
+ "client_secret":CLIENT_SECRET}
+ try:
+ res = requests.post(f"https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token",
+ data=data,
+ timeout=5)
+ if res.status_code != 200:
+ abort(make_response((
+ {"message":
+ "An error occured while trying to authenticate your authorization code"},
+ 500)))
+ token = res.json()["access_token"]
+ profile_res = requests.get("https://graph.microsoft.com/v1.0/me",
+ headers={"Authorization":f"Bearer {token}"},
+ timeout=5)
+ except TimeoutError:
+ return {"message":"Request to Microsoft timed out"}, 500
+ if not profile_res or profile_res.status_code != 200:
+ abort(make_response(({"message":
+ "An error occured while trying to authenticate your access token"},
+ 500)))
+ auth_user_id = profile_res.json()["id"]
+ try:
+ user = db.session.get(User, auth_user_id)
+ except SQLAlchemyError:
+ db.session.rollback()
+ abort(make_response(({"message":
+ "An unexpected database error occured while fetching the user"},
+ 500)))
+
+ if not user:
+ role = Role.STUDENT
+ if profile_res.json()["jobTitle"] is not None:
+ role = Role.TEACHER
+
+ # add user if not yet in database
+ try:
+ new_user = User(uid=auth_user_id,
+ role=role,
+ display_name=profile_res.json()["displayName"])
+ db.session.add(new_user)
+ db.session.commit()
+ user = new_user
+ except SQLAlchemyError:
+ db.session.rollback()
+ abort(make_response(({"message":
+ """An unexpected database error occured
+ while creating the user during authentication"""}, 500)))
+ resp = redirect(HOMEPAGE_URL, code=303)
+ additional_claims = {"is_teacher":user.role == Role.TEACHER,
+ "is_admin":user.role == Role.ADMIN}
+ set_access_cookies(resp,
+ create_access_token(identity=profile_res.json()["id"],
+ additional_claims=additional_claims))
+ return resp
+
+
+def test_authentication():
+ """
+ This function will handle the logins done using our
+ own authentication server for testing purposes
+ """
+ code = request.args.get("code")
+ if code is None:
+ return {"message":"Not yet"}, 500
+ profile_res = requests.get(AUTHENTICATION_URL, headers={"Authorization":f"{code}"}, timeout=5)
+ resp = redirect(HOMEPAGE_URL, code=303)
+ set_access_cookies(resp, create_access_token(identity=profile_res.json()["id"]))
+ return resp
+
+
+class Auth(Resource):
+ """Api endpoint for the /auth route"""
+
+ def get(self):
+ """
+ Will handle the request according to the method defined in the .env variables.
+ Currently only Microsoft and our test authentication are supported
+ """
+ if AUTH_METHOD == "Microsoft":
+ return microsoft_authentication()
+ return test_authentication()
+
+auth_api.add_resource(Auth, "/auth")
diff --git a/backend/project/endpoints/authentication/logout.py b/backend/project/endpoints/authentication/logout.py
new file mode 100644
index 00000000..e629bbe3
--- /dev/null
+++ b/backend/project/endpoints/authentication/logout.py
@@ -0,0 +1,28 @@
+"""Api endpoint to handle logout requests"""
+from os import getenv
+
+from dotenv import load_dotenv
+from flask import Blueprint, redirect
+from flask_jwt_extended import unset_jwt_cookies, jwt_required
+from flask_restful import Resource, Api
+
+logout_bp = Blueprint("logout", __name__)
+logout_api = Api(logout_bp)
+
+load_dotenv()
+HOMEPAGE_URL = getenv("HOMEPAGE_URL")
+
+class Logout(Resource):
+ """Api endpoint for the /auth route"""
+
+ @jwt_required()
+ def get(self):
+ """
+ Will handle the request according to the method defined in the .env variables.
+ Currently only Microsoft and our test authentication are supported
+ """
+ resp = redirect(HOMEPAGE_URL, 303)
+ unset_jwt_cookies(resp)
+ return resp
+
+logout_api.add_resource(Logout, "/logout")
diff --git a/backend/project/endpoints/authentication/me.py b/backend/project/endpoints/authentication/me.py
new file mode 100644
index 00000000..c4f19a70
--- /dev/null
+++ b/backend/project/endpoints/authentication/me.py
@@ -0,0 +1,33 @@
+"""User info api endpoint"""
+from os import getenv
+
+from dotenv import load_dotenv
+from flask import Blueprint
+from flask_jwt_extended import get_jwt_identity, jwt_required
+from flask_restful import Resource, Api
+
+from project.models.user import User
+from project.utils.query_agent import query_by_id_from_model
+
+load_dotenv()
+API_URL = getenv("API_HOST")
+
+me_bp = Blueprint("me", __name__)
+me_api = Api(me_bp)
+
+class Me(Resource):
+ """Api endpoint for the /user_info route"""
+
+ @jwt_required()
+ def get(self):
+ """
+ Will return all user data associated with the access token in the request
+ """
+ uid = get_jwt_identity
+
+ return query_by_id_from_model(User,
+ "uid",
+ uid,
+ "Could not find you in the database, please log in again")
+
+me_api.add_resource(Me, "/me")
diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py
index ae05894f..5f882c76 100644
--- a/backend/project/endpoints/projects/projects.py
+++ b/backend/project/endpoints/projects/projects.py
@@ -106,8 +106,8 @@ def post(self, teacher_id=None):
if status_code == 400:
return new_project, status_code
- project_upload_directory = os.path.join(f"{UPLOAD_FOLDER}", f"{new_project.project_id}")
+ project_upload_directory = os.path.join(f"{UPLOAD_FOLDER}", f"{new_project.project_id}")
os.makedirs(project_upload_directory, exist_ok=True)
if filename is not None:
try:
diff --git a/backend/project/init_auth.py b/backend/project/init_auth.py
new file mode 100644
index 00000000..bc20c497
--- /dev/null
+++ b/backend/project/init_auth.py
@@ -0,0 +1,56 @@
+""" This file will change the JWT return messages to custom messages
+ and make it so the access tokens implicitly refresh
+"""
+from datetime import timedelta, timezone, datetime
+
+from flask_jwt_extended import get_jwt, get_jwt_identity,\
+ create_access_token, set_access_cookies
+
+def auth_init(jwt, app):
+ """
+ This function will overwrite the default return messages from
+ the flask-jwt-extended package with custom messages
+ and make it so the access tokens implicitly refresh
+ """
+ @jwt.expired_token_loader
+ def expired_token_callback(jwt_header, jwt_payload):
+ return (
+ {"message":"Your access token cookie has expired, please log in again"},
+ 401)
+
+ @jwt.invalid_token_loader
+ def invalid_token_callback(jwt_header, jwt_payload):
+ return (
+ {"message":("The server cannot recognize this access token cookie, "
+ "please log in again if you think this is an error")},
+ 401)
+
+ @jwt.revoked_token_loader
+ def revoked_token_callback(jwt_header, jwt_payload):
+ return (
+ {"message":("This access token cookie has been revoked, "
+ "possibly from logging out. Log in again to receive a new access token")},
+ 401)
+
+ @jwt.unauthorized_loader
+ def unauthorized_callback(jwt_header):
+ return {"message":"You need an access token to get this data, please log in"}, 401
+
+ @app.after_request
+ def refresh_expiring_jwts(response):
+ try:
+ exp_timestamp = get_jwt()["exp"]
+ now = datetime.now(timezone.utc)
+ target_timestamp = datetime.timestamp(now + timedelta(minutes=30))
+ if target_timestamp > exp_timestamp:
+ access_token = create_access_token(
+ identity=get_jwt_identity(),
+ additional_claims=
+ {"is_admin":get_jwt()["is_admin"],
+ "is_teacher":get_jwt()["is_teacher"]}
+ )
+ set_access_cookies(response, access_token)
+ return response
+ except (RuntimeError, KeyError):
+ # Case where there is not a valid JWT. Just return the original response
+ return response
diff --git a/backend/project/utils/authentication.py b/backend/project/utils/authentication.py
index ad9ba85f..b59ff281 100644
--- a/backend/project/utils/authentication.py
+++ b/backend/project/utils/authentication.py
@@ -8,17 +8,13 @@
from dotenv import load_dotenv
from flask import abort, request, make_response
-import requests
-from sqlalchemy.exc import SQLAlchemyError
+from flask_jwt_extended import get_jwt, get_jwt_identity, verify_jwt_in_request
-from project import db
-
-from project.models.user import User, Role
from project.utils.models.course_utils import is_admin_of_course, \
is_student_of_course, is_teacher_of_course
from project.utils.models.project_utils import get_course_of_project, project_visible
from project.utils.models.submission_utils import get_submission, get_course_of_submission
-from project.utils.models.user_utils import is_admin, is_teacher
+from project.utils.models.user_utils import get_user
load_dotenv()
API_URL = getenv("API_HOST")
@@ -34,58 +30,13 @@ def wrap(*args, **kwargs):
def return_authenticated_user_id():
- """This function will authenticate the request and check whether the authenticated user
- is already in the database, if not, they will be added
+ """This function will authenticate the request and ensure the user was added to the database,
+ otherwise it will prompt them to login again
"""
- authentication = request.headers.get("Authorization")
- if not authentication:
- abort(
- make_response((
- {"message":
- "No authorization given, you need an access token to use this API"},
- 401)))
-
- auth_header = {"Authorization": authentication}
- try:
- response = requests.get(
- AUTHENTICATION_URL, headers=auth_header, timeout=5)
- except TimeoutError:
- abort(make_response(
- ({"message": "Request to Microsoft timed out"}, 500)))
- if not response or response.status_code != 200:
- abort(make_response(({"message":
- "An error occured while trying to authenticate your access token"},
- 401)))
-
- user_info = response.json()
- auth_user_id = user_info["id"]
- try:
- user = db.session.get(User, auth_user_id)
- except SQLAlchemyError:
- db.session.rollback()
- abort(make_response(({"message":
- "An unexpected database error occured while fetching the user"},
- 500)))
-
- if user:
- return auth_user_id
-
- # Use the Enum here
- role = Role.STUDENT
- if user_info["jobTitle"] is not None:
- role = Role.TEACHER
-
- # add user if not yet in database
- try:
- new_user = User(uid=auth_user_id, role=role)
- db.session.add(new_user)
- db.session.commit()
- except SQLAlchemyError:
- db.session.rollback()
- abort(make_response(({"message":
- """An unexpected database error occured
- while creating the user during authentication"""}, 500)))
- return auth_user_id
+ verify_jwt_in_request()
+ uid = get_jwt_identity()
+ get_user(uid)
+ return uid
def login_required(f):
@@ -118,8 +69,8 @@ def authorize_admin(f):
"""
@wraps(f)
def wrap(*args, **kwargs):
- auth_user_id = return_authenticated_user_id()
- if is_admin(auth_user_id):
+ return_authenticated_user_id()
+ if get_jwt()["is_admin"]:
return f(*args, **kwargs)
abort(make_response(({"message":
"""You are not authorized to perfom this action,
@@ -135,7 +86,7 @@ def authorize_teacher(f):
@wraps(f)
def wrap(*args, **kwargs):
auth_user_id = return_authenticated_user_id()
- if is_teacher(auth_user_id):
+ if get_jwt()["is_teacher"]:
kwargs["teacher_id"] = auth_user_id
return f(*args, **kwargs)
abort(make_response(({"message":
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 9b016df4..12a02f7e 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -1,5 +1,6 @@
flask~=3.0.2
flask-cors
+flask-jwt-extended
flask-restful
flask-sqlalchemy
sqlalchemy_utils
diff --git a/backend/test_auth_server/__main__.py b/backend/test_auth_server/__main__.py
index adaea5b8..fe981a74 100644
--- a/backend/test_auth_server/__main__.py
+++ b/backend/test_auth_server/__main__.py
@@ -1,5 +1,4 @@
"""Main entry point for the application."""
-
from dotenv import load_dotenv
from flask import Flask, Blueprint, request
from flask_restful import Resource, Api
diff --git a/backend/tests.yaml b/backend/tests.yaml
index 5c232c18..f2c2c82e 100644
--- a/backend/tests.yaml
+++ b/backend/tests.yaml
@@ -43,6 +43,8 @@ services:
POSTGRES_DB: test_database
API_HOST: http://api_is_here
AUTHENTICATION_URL: http://auth-server:5001 # Use the service name defined in Docker Compose
+ AUTH_METHOD: test
+ JWT_SECRET_KEY: Test123
UPLOAD_URL: /data/assignments
DOCS_JSON_PATH: static/OpenAPI_Object.yaml
DOCS_URL: /docs
diff --git a/backend/tests/endpoints/project_test.py b/backend/tests/endpoints/project_test.py
index a65aa38c..c750cd56 100644
--- a/backend/tests/endpoints/project_test.py
+++ b/backend/tests/endpoints/project_test.py
@@ -11,18 +11,18 @@ def test_assignment_download(client, valid_project):
with open("tests/resources/testzip.zip", "rb") as zip_file:
valid_project["assignment_file"] = zip_file
# post the project
- response = client.post(
- "/projects",
- data=valid_project,
- content_type='multipart/form-data',
- headers={"Authorization":"teacher"}
- )
- assert response.status_code == 201
- project_id = response.json["data"]["project_id"]
- response = client.get(f"/projects/{project_id}/assignment",
- headers={"Authorization":"teacher"})
- # 404 because the file is not found, no assignment.md in zip file
- assert response.status_code == 404
+ with client:
+ response = client.get("/auth?code=teacher")
+ response = client.post(
+ "/projects",
+ data=valid_project,
+ content_type='multipart/form-data',
+ )
+ assert response.status_code == 201
+ project_id = response.json["data"]["project_id"]
+ response = client.get(f"/projects/{project_id}/assignment")
+ # 404 because the file is not found, no assignment.md in zip file
+ assert response.status_code == 404
def test_not_found_download(client):
diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx
index d807c5b8..b15d188c 100644
--- a/frontend/src/components/Header/Header.tsx
+++ b/frontend/src/components/Header/Header.tsx
@@ -1,7 +1,6 @@
import {
AppBar,
Box,
- Button,
IconButton,
Menu,
MenuItem,
@@ -19,6 +18,7 @@ import { useEffect, useState } from "react";
import LanguageIcon from "@mui/icons-material/Language";
import { Link } from "react-router-dom";
import { TitlePortal } from "./TitlePortal";
+import {LoginButton} from "./Login";
/**
* The header component for the application that will be rendered at the top of the page.
@@ -68,8 +68,8 @@ export function Header(): JSX.Element {