diff --git a/backend/app.py b/backend/app.py index 77410530..58f73dfc 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1,7 +1,7 @@ import uvicorn from fastapi import FastAPI -from routes.session import session_router +from routes.authentication import session_router from routes.teachers import teachers_router app = FastAPI() diff --git a/backend/application.properties b/backend/application.properties new file mode 100644 index 00000000..2cc2f145 --- /dev/null +++ b/backend/application.properties @@ -0,0 +1,4 @@ +[session] +service = https://localhost:8080/api/login +cookie_domain = localhost +max_cookie_age = 86400 \ No newline at end of file diff --git a/backend/controllers/auth/session_controller.py b/backend/controllers/auth/authentication_controller.py similarity index 65% rename from backend/controllers/auth/session_controller.py rename to backend/controllers/auth/authentication_controller.py index 187ac3af..9797221b 100644 --- a/backend/controllers/auth/session_controller.py +++ b/backend/controllers/auth/authentication_controller.py @@ -1,15 +1,14 @@ import httpx from defusedxml.ElementTree import fromstring -# test url: https://login.ugent.be/login?service=https://localhost:8080/session -# TODO: get information out of a properties file -SERVICE = "https://localhost:8080/session" -DOMAIN = "localhost" -MAX_AGE = 24 * 60 * 60 +from controllers.properties.Properties import Properties +props: Properties = Properties() -def get_user_information(ticket: str) -> dict | None: - user_information = httpx.get(f"https://login.ugent.be/serviceValidate?service={SERVICE}&ticket={ticket}" + +def authenticate_user(ticket: str) -> dict | None: + service = props.get("session", "service") + user_information = httpx.get(f"https://login.ugent.be/serviceValidate?service={service}&ticket={ticket}" , headers={"Accept": "application/json,text/html"}, ) user: dict | None = parse_cas_xml(user_information.text) @@ -31,11 +30,6 @@ def parse_cas_xml(xml: str) -> dict | None: mail = attributes_xml.find(f"{namespace}mail").text user["name"] = f"{givenname} {surname}" - user["mail"] = mail + user["mail"] = mail.lower() return user return None - - -# TODO: create a session_id for the given user, create the user if it doesn't exist already -def login_user(user_information: dict) -> str: - return "TestValueCookie" diff --git a/backend/controllers/auth/cookie_controller.py b/backend/controllers/auth/cookie_controller.py new file mode 100644 index 00000000..5e873b16 --- /dev/null +++ b/backend/controllers/auth/cookie_controller.py @@ -0,0 +1,27 @@ +from fastapi import Request, Response + +from controllers.auth.encryption_controller import encrypt, generate_keys +from controllers.properties.Properties import Properties + +props: Properties = Properties() + + +def set_cookies(response: Response, key: str, value: str) -> Response: + value: str = encrypt(value) + max_age: int = int(props.get("session", "max_cookie_age")) + domain: str = props.get("session", "cookie_domain") + response.set_cookie(key=key, + value=value, + max_age=max_age, + domain=domain, + secure=False) + return response + + +def get_cookie(request: Request, key: str) -> str: + return request.cookies.get(key) + + +def delete_cookie(response: Response, cookie_tag: str) -> Response: + response.delete_cookie(cookie_tag) + return response diff --git a/backend/controllers/auth/encryption_controller.py b/backend/controllers/auth/encryption_controller.py new file mode 100644 index 00000000..20253e0f --- /dev/null +++ b/backend/controllers/auth/encryption_controller.py @@ -0,0 +1,53 @@ +import base64 + +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import padding, rsa +from cryptography.hazmat.primitives.asymmetric.padding import OAEP +from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPublicKey + +# TODO: Use database to manage keys instead of dict +keys: dict = {} + + +def generate_keys(user_id: str) -> RSAPublicKey: + if user_id in keys: + return keys[user_id].public_key() + + private_key: RSAPrivateKey = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + ) + keys[user_id] = private_key + return private_key.public_key() + + +def encrypt(user_id: str) -> str: + key = generate_keys(user_id) + encryption: bytes = key.encrypt( + plaintext=user_id.encode(), + padding=OAEP( + mgf=padding.MGF1(algorithm=hashes.SHA256()), + algorithm=hashes.SHA256(), + label=None, + ), + ) + return base64.b64encode(encryption).decode() + + +def decrypt(user_id: str, encrypted_text: str) -> str | None: + if user_id in keys: + private_key: RSAPrivateKey = keys[user_id] + encryption: bytes = base64.b64decode(encrypted_text.encode()) + return private_key.decrypt( + encryption, + padding=OAEP( + mgf=padding.MGF1(algorithm=hashes.SHA256()), + algorithm=hashes.SHA256(), + label=None, + ), + ).decode() + return None + + +def delete_key(user_id: str) -> None: + keys.pop(user_id) diff --git a/backend/controllers/auth/login_controller.py b/backend/controllers/auth/login_controller.py new file mode 100644 index 00000000..77d4a0c8 --- /dev/null +++ b/backend/controllers/auth/login_controller.py @@ -0,0 +1,16 @@ +from fastapi import Request + +from controllers.auth.cookie_controller import get_cookie +from controllers.auth.encryption_controller import decrypt +from controllers.properties.Properties import Properties + +# test url: https://login.ugent.be/login?service=https://localhost:8080/api/login +props: Properties = Properties() + + +def verify_session(request: Request, user_id: str) -> bool: + session: str = get_cookie(request, "session_id") + if session: + decryption = decrypt(user_id=user_id, encrypted_text=session) + return user_id == decryption if decryption else False + return False diff --git a/backend/controllers/properties/Properties.py b/backend/controllers/properties/Properties.py new file mode 100644 index 00000000..951fdd0b --- /dev/null +++ b/backend/controllers/properties/Properties.py @@ -0,0 +1,15 @@ +import configparser + + +class Properties: + def __init__(self) -> None: + config = configparser.ConfigParser() + config.read("application.properties") + self.properties: dict = {} + for section in config.sections(): + self.properties[section] = {} + for key, val in config[section].items(): + self.properties[section][key] = val + + def get(self, section: str, key: str) -> str: + return self.properties[section][key] diff --git a/backend/requirements.txt b/backend/requirements.txt index 01b761ff..e5cc8d55 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -20,4 +20,5 @@ starlette==0.36.3 typing_extensions==4.10.0 uvicorn==0.27.1 httpx==0.27.0 -defusedxml~=0.7.1 \ No newline at end of file +defusedxml~=0.7.1 +cryptography~=42.0.5 \ No newline at end of file diff --git a/backend/routes/authentication.py b/backend/routes/authentication.py new file mode 100644 index 00000000..176b8df3 --- /dev/null +++ b/backend/routes/authentication.py @@ -0,0 +1,56 @@ +from fastapi import APIRouter, Request, Response +from fastapi.responses import JSONResponse + +from controllers.auth.authentication_controller import authenticate_user +from controllers.auth.cookie_controller import delete_cookie, set_cookies +from controllers.auth.encryption_controller import delete_key +from controllers.auth.login_controller import verify_session + +session_router = APIRouter() + + +@session_router.get("/api/login") +def login(ticket: str) -> JSONResponse: + """ + This function start a session for the user. + For authentication, it uses the given ticket and the UGent CAS server (https://login.ugent.be). + + :param ticket: A UGent CAS ticket that will be used for the authentication + :return: + - Valid Ticket: A JSONResponse with a user object; a cookie will be set with a session_id + - Invalid Ticket: A JSONResponse with status_code 401 and an error message + """ + user: dict = authenticate_user(ticket) # This should be a user object + if user: + response: JSONResponse = JSONResponse(content=user) + # TODO: Change mail to user id + print("here") + response = set_cookies(response, "session_id", user["mail"]) + return response + return JSONResponse(status_code=401, content="Invalid Ticket") + + +@session_router.get("/api/logout/{user_id}") +def logout(user_id: str) -> Response: + """ + This function will log a user out, by removing the session from storage + + :param user_id: An identifier of the user that needs to be logged out + :return: A confirmation that the logout was successful, and it tells the browser to remove the cookie. + """ + response: Response = Response(content="You've been successfully logged out") + + delete_cookie(response, "session_id") + delete_key(user_id) + return response + + +@session_router.get("/api/session/verify/{user_id}") +def verify(request: Request, user_id: str) -> bool: + """ + A test route to check if the user has a valid session + :param request: Http Request filled in by fastapi + :param user_id: identifier for the user + :return: boolean that says if the user is logged in + """ + return verify_session(request, user_id) diff --git a/backend/routes/session.py b/backend/routes/session.py deleted file mode 100644 index 2bb8910c..00000000 --- a/backend/routes/session.py +++ /dev/null @@ -1,22 +0,0 @@ -from fastapi import APIRouter, HTTPException -from fastapi.responses import JSONResponse - -from controllers.auth.session_controller import DOMAIN, MAX_AGE, get_user_information, login_user - -session_router = APIRouter() - - -@session_router.get("/session") -def get_session(ticket: str) -> JSONResponse: - user_information: dict = get_user_information(ticket) - if user_information: - session_id = login_user(user_information) - response: JSONResponse = JSONResponse(content=user_information) - response.set_cookie( - key="session_id", - value=session_id, - max_age=MAX_AGE, - domain=DOMAIN, - secure=True) - return response - raise HTTPException(status_code=401, detail="Invalid Token!")