Skip to content

Commit

Permalink
Full authentication (#216)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
Vucis authored Apr 18, 2024
1 parent 2630299 commit 99ac445
Show file tree
Hide file tree
Showing 16 changed files with 328 additions and 79 deletions.
2 changes: 1 addition & 1 deletion backend/Dockerfile_auth_test
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
File renamed without changes.
26 changes: 25 additions & 1 deletion backend/project/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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():
"""
Expand All @@ -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)
Expand All @@ -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):
Expand All @@ -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
125 changes: 125 additions & 0 deletions backend/project/endpoints/authentication/auth.py
Original file line number Diff line number Diff line change
@@ -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")
28 changes: 28 additions & 0 deletions backend/project/endpoints/authentication/logout.py
Original file line number Diff line number Diff line change
@@ -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")
33 changes: 33 additions & 0 deletions backend/project/endpoints/authentication/me.py
Original file line number Diff line number Diff line change
@@ -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")
2 changes: 1 addition & 1 deletion backend/project/endpoints/projects/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
56 changes: 56 additions & 0 deletions backend/project/init_auth.py
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 99ac445

Please sign in to comment.