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 { setOpen(!open)} sx={{ color: "white", marginLeft: 0 }}> - - + +
diff --git a/frontend/src/components/Header/Login.tsx b/frontend/src/components/Header/Login.tsx new file mode 100644 index 00000000..d06bf021 --- /dev/null +++ b/frontend/src/components/Header/Login.tsx @@ -0,0 +1,16 @@ +import {Button} from "@mui/material"; +import { Link } from 'react-router-dom'; + +const CLIENT_ID = import.meta.env.VITE_APP_CLIENT_ID; +const REDIRECT_URI = encodeURI(import.meta.env.VITE_APP_API_HOST + "/auth"); +const TENANT_ID = import.meta.env.VITE_APP_TENANT_ID; + +/** + * The login component for the application that will redirect to the correct login link. + * @returns - A login button + */ +export function LoginButton(): JSX.Element { + const link = `https://login.microsoftonline.com/${TENANT_ID}/oauth2/v2.0/authorize?prompt=select_account&response_type=code&client_id=${CLIENT_ID}&redirect_uri=${REDIRECT_URI}&scope=.default`; + + return +} diff --git a/frontend/src/components/Header/Logout.tsx b/frontend/src/components/Header/Logout.tsx new file mode 100644 index 00000000..2e569bd0 --- /dev/null +++ b/frontend/src/components/Header/Logout.tsx @@ -0,0 +1,14 @@ +import {Button} from "@mui/material"; +import {Link} from 'react-router-dom'; + +const API_HOST = import.meta.env.VITE_APP_API_HOST; + +/** + * The Logout component for the application that will redirect to the correct logout link. + * @returns - A Logout button + */ +export function LogoutButton(): JSX.Element { + const link = `${API_HOST}/logout`; + + return +} \ No newline at end of file