diff --git a/backend/.gitignore b/backend/.gitignore index 5c2da223..92bdbae7 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1,6 +1,6 @@ __pycache__ *venv* -local-cert/ + *.db config.yml .env diff --git a/backend/README.md b/backend/README.md index 4672a1c4..78fa9b53 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,31 +1,26 @@ -## Run the backend api +# Backend API -### Setup +## Running the API -#### In this directy execute the following command to create a python environment: +### Setup ```sh +# Create a python virtual environment python -m venv venv -``` - -#### Activate the environment: - -```sh +# Activate the environment source venv/bin/activate -``` - -#### Install the dependencies: - -```sh +# Install dependencies pip install -r requirements.txt ``` -#### Create a config.yml file with following content +#### Create a `.env` file with following content ```yml -api_url: https://localhost:8080 -cas_server_url: https://login.ugent.be -database_uri: "database connection string: postgresql://..., see discord..." +FRONTEND_URL="https://localhost:8080" +CAS_SERVER_URL="https://login.ugent.be" +DATABASE_URI="database connection string: postgresql://..., see discord..." +SECRET_KEY="" # e.g. generate with `openssl rand -hex 32` +ALGORITHM="HS256" # algorithm used to sign JWT tokens ``` ### Usage @@ -38,21 +33,27 @@ source venv/bin/activate #### Run the web server: -> Note: For local development, an SSL-certificate is needed to interact with the -> CAS-server of UGent. Install [mkcert](https://github.com/FiloSottile/mkcert) -> and run -> ```sh -> mkdir local-cert -> mkcert -key-file local-cert/localhost-key.pem -cert-file local-cert/localhost.pem localhost -> ``` - ```sh ./run.sh ``` -It will start a local development server on port `8080` +This will start a local development server on port `5173` + +## The API + +## Login +Authentication happens with the use of CAS. The client can ask where it can find +the CAS server with the `/api/authority` endpoint. A ticket then can be obtained +via `?service=`. The CAS server will redirect to +`?ticket=` after authentication. Once the client is +authenticated, further authorization happens with [JWT](https://jwt.io/). To +obtain this token, a `POST` request has to be made to `/api/token/`, with the +CAS ticket `` and the ``. The redirect url is needed to +verify the ticket. If the ticket is valid, a webtoken will be returned. To +authorize each request, add the token in the `Authorization` header. +## Developing #### To format the python code in place to conform to PEP 8 style: diff --git a/backend/requirements.txt b/backend/requirements.txt index 9fe5e810..b6c4a549 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -4,14 +4,15 @@ attrs==23.1.0 autopep8==2.0.4 cattrs==23.1.2 certifi==2024.2.2 +cffi==1.16.0 charset-normalizer==3.3.2 click==8.1.7 +cryptography==42.0.5 exceptiongroup==1.1.2 fastapi==0.109.2 greenlet==3.0.3 h11==0.14.0 httptools==0.6.1 -httpx==0.27.0 idna==3.6 itsdangerous==2.1.2 lsprotocol==2023.0.0a2 @@ -19,22 +20,22 @@ lxml==5.1.0 nodeenv==1.8.0 psycopg2-binary==2.9.9 pycodestyle==2.11.1 +pycparser==2.21 pydantic==2.6.1 pydantic_core==2.16.2 pygls==1.0.1 +PyJWT==2.8.0 pyright==1.1.352 python-cas==1.6.0 python-dotenv==1.0.1 PyYAML==6.0.1 requests==2.31.0 -ruff-lsp==0.0.38 -setuptools==69.1.1 six==1.16.0 sniffio==1.3.0 SQLAlchemy==2.0.27 starlette==0.36.3 typeguard==2.13.3 -typing_extensions>=4.7.1 +typing_extensions==4.10.0 urllib3==2.2.1 uvicorn==0.27.1 watchfiles==0.21.0 diff --git a/backend/run.sh b/backend/run.sh index a2a76d2f..3798c619 100755 --- a/backend/run.sh +++ b/backend/run.sh @@ -1,5 +1,3 @@ #!/usr/bin/env bash -uvicorn src.main:app --reload --port 8080 \ - --ssl-keyfile "local-cert/localhost-key.pem" \ - --ssl-certfile "local-cert/localhost.pem" +uvicorn src.main:app --reload --port 5173 diff --git a/backend/src/auth/dependencies.py b/backend/src/auth/dependencies.py new file mode 100644 index 00000000..407aa119 --- /dev/null +++ b/backend/src/auth/dependencies.py @@ -0,0 +1,36 @@ +import jwt +from fastapi import Depends +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from src.config import CONFIG + +from .exceptions import UnAuthenticated + + +def verify_jwt_token( + credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer()), +) -> str: + """ + Verify the JWT token and return the user_id + Args: + credentials (HTTPAuthorizationCredentials): The credentials from the request + Returns: + str: The user_id + Raises: + UnAuthenticated: If the token is invalid or expired + """ + try: + payload = jwt.decode( + credentials.credentials, + CONFIG.secret_key, + algorithms=[CONFIG.algorithm], + verify_signature=True, + options={"require": ["exp", "sub"]}, + ) + user_id = payload["sub"] + return user_id + except (jwt.ExpiredSignatureError, jwt.MissingRequiredClaimError): + # Token is expired or no expiration time is set + raise UnAuthenticated + except jwt.InvalidTokenError: + # Token is invalid + raise UnAuthenticated diff --git a/backend/src/auth/exceptions.py b/backend/src/auth/exceptions.py new file mode 100644 index 00000000..a28090d5 --- /dev/null +++ b/backend/src/auth/exceptions.py @@ -0,0 +1,13 @@ +from fastapi import HTTPException, status + + +class NotAuthorized(HTTPException): + def __init__(self, detail: str = "Not authorized"): + """Raised when user is not privileged enough to do this action""" + super().__init__(status_code=status.HTTP_403_FORBIDDEN, detail=detail) + + +class UnAuthenticated(HTTPException): + def __init__(self, detail: str = "Login required"): + """Raised when user not logged in""" + super().__init__(status_code=status.HTTP_401_UNAUTHORIZED, detail=detail) diff --git a/backend/src/auth/router.py b/backend/src/auth/router.py new file mode 100644 index 00000000..2af6c133 --- /dev/null +++ b/backend/src/auth/router.py @@ -0,0 +1,73 @@ +import src.user.service as user_service +from cas import CASClient +from fastapi import APIRouter, Depends, Request +from fastapi.responses import RedirectResponse +from sqlalchemy.orm import Session +from src import config +from src.auth.schemas import Authority, Token, TokenRequest +from src.dependencies import get_db +from src.user.schemas import UserCreate + +from .exceptions import UnAuthenticated +from .utils import create_jwt_token + +router = APIRouter( + prefix="/api", tags=["auth"], responses={404: {"description": "Not Found"}} +) + +cas_client = CASClient( + version=2, + server_url=f"{config.CONFIG.cas_server_url}", +) + + +@router.get("/authority") +def authority() -> Authority: + """ + Get CAS authority + """ + return Authority(method="cas", authority=config.CONFIG.cas_server_url) + + +@router.post("/token") +async def token( + request: Request, + token_request: TokenRequest, + db: Session = Depends(get_db), +) -> Token: + """ + Get JWT token from CAS ticket + """ + # No ticket provided + if not token_request.ticket: + raise UnAuthenticated(detail="No ticket provided") + + cas_client.service_url = f"{request.headers.get('origin')}{token_request.returnUrl}" + user, attributes, _ = cas_client.verify_ticket(token_request.ticket) + + # Invalid ticket + if not user or not attributes: + raise UnAuthenticated(detail="Invalid CAS ticket") + # Create user if not exists + if not await user_service.get_by_id(db, attributes["uid"]): + await user_service.create_user( + db, + UserCreate( + given_name=attributes["givenname"], + uid=attributes["uid"], + mail=attributes["mail"], + ), + ) + + # Create JWT token + jwt_token = create_jwt_token(attributes["uid"]) + return jwt_token + + +@router.get("/logout") +async def logout() -> RedirectResponse: + """ + Logout from CAS + """ + cas_logout_url = cas_client.get_logout_url() + return RedirectResponse(cas_logout_url) diff --git a/backend/src/auth/schemas.py b/backend/src/auth/schemas.py new file mode 100644 index 00000000..73f29f07 --- /dev/null +++ b/backend/src/auth/schemas.py @@ -0,0 +1,16 @@ +from pydantic import BaseModel + + +class Token(BaseModel): + token: str + token_type: str + + +class TokenRequest(BaseModel): + ticket: str + returnUrl: str + + +class Authority(BaseModel): + method: str + authority: str diff --git a/backend/src/auth/utils.py b/backend/src/auth/utils.py new file mode 100644 index 00000000..dcc11da8 --- /dev/null +++ b/backend/src/auth/utils.py @@ -0,0 +1,18 @@ +import jwt +from src import config +from src.auth.schemas import Token +from datetime import datetime, timedelta, timezone + + +def create_jwt_token(user_id: str) -> Token: + now = datetime.now(timezone.utc) + payload = { + "sub": user_id, + "iat": now, + # TODO: don't hardcode this + "exp": now + timedelta(weeks=1), + } + token = jwt.encode( + payload, config.CONFIG.secret_key, algorithm=config.CONFIG.algorithm + ) + return Token(token=token, token_type="bearer") diff --git a/backend/src/config.py b/backend/src/config.py index fb785650..050bf48c 100644 --- a/backend/src/config.py +++ b/backend/src/config.py @@ -1,20 +1,36 @@ -from __future__ import annotations -from dotenv import load_dotenv import os from dataclasses import dataclass +from dotenv import load_dotenv + load_dotenv() @dataclass class Config: - api_url: str + frontend_url: str cas_server_url: str database_uri: str + secret_key: str + algorithm: str + + +env = { + "frontend_url": os.getenv("FRONTEND_URL", ""), + "cas_server_url": os.getenv("CAS_SERVER_URL", ""), + "database_uri": os.getenv("DATABASE_URI", ""), + "secret_key": os.getenv("SECRET_KEY", ""), + "algorithm": os.getenv("ALGORITHM", ""), +} +for key, value in env.items(): + if value == "": + raise ValueError(f"Environment variable {key} is not set") CONFIG = Config( - os.getenv("API_URL", "https://localhost:8000"), - os.getenv("CAS_SERVER", "https://login.ugent.be"), - os.getenv("DATABASE_URI", "CONNECtOON_STRING") + frontend_url=env["frontend_url"], + cas_server_url=env["cas_server_url"], + database_uri=env["database_uri"], + secret_key=env["secret_key"], + algorithm=env["algorithm"], ) diff --git a/backend/src/main.py b/backend/src/main.py index 71c78d55..18010993 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -2,13 +2,26 @@ from starlette.middleware.sessions import SessionMiddleware from src.subject.router import router as subject_router from src.user.router import router as user_router +from src.auth.router import router as auth_router +from fastapi.middleware.cors import CORSMiddleware +from src import config app = FastAPI() app.add_middleware(SessionMiddleware, secret_key="!secret") +origins = [config.CONFIG.frontend_url] +app.add_middleware( + CORSMiddleware, + allow_origins=origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + app.include_router(subject_router) app.include_router(user_router) +app.include_router(auth_router) @app.get("/api") diff --git a/backend/src/subject/dependencies.py b/backend/src/subject/dependencies.py index 92435cd9..219190f7 100644 --- a/backend/src/subject/dependencies.py +++ b/backend/src/subject/dependencies.py @@ -2,7 +2,7 @@ from sqlalchemy.orm import Session from src.dependencies import get_db from src.user.dependencies import get_authenticated_user -from src.user.exceptions import NotAuthorized +from src.auth.exceptions import NotAuthorized from src.user.schemas import User from . import service diff --git a/backend/src/user/dependencies.py b/backend/src/user/dependencies.py index efb516f7..73e9f121 100644 --- a/backend/src/user/dependencies.py +++ b/backend/src/user/dependencies.py @@ -1,21 +1,21 @@ from sqlalchemy.orm import Session +from src.auth.dependencies import verify_jwt_token from src.dependencies import get_db -from src.user.exceptions import NotAuthorized from fastapi import Depends from .schemas import User -from .exceptions import UserNotFound, UnAuthenticated +from .exceptions import UserNotFound +from src.auth.exceptions import UnAuthenticated, NotAuthorized from starlette.requests import Request -import src.user.service as service +import src.user.service as user_service async def get_authenticated_user( - request: Request, db: Session = Depends(get_db) + user_id: str = Depends(verify_jwt_token), db: Session = Depends(get_db) ) -> User: """Get current logged in user""" - user_id = request.session.get("user") if not user_id: raise UnAuthenticated() - user = await service.get_by_id(db, user_id["user"]) + user = await user_service.get_by_id(db, user_id) if not user: raise UserNotFound() @@ -29,6 +29,6 @@ async def admin_user_validation(user: User = Depends(get_authenticated_user)): async def user_id_validation(user_id: str, db: Session = Depends(get_db)): - user = await service.get_by_id(db, user_id) + user = await user_service.get_by_id(db, user_id) if not user: raise UserNotFound() diff --git a/backend/src/user/exceptions.py b/backend/src/user/exceptions.py index 9f219ca2..8df7fbde 100644 --- a/backend/src/user/exceptions.py +++ b/backend/src/user/exceptions.py @@ -5,15 +5,3 @@ class UserNotFound(HTTPException): def __init__(self): """Raised when user not found in database""" super().__init__(status_code=404, detail="User not found") - - -class UnAuthenticated(HTTPException): - def __init__(self): - """Raised when user not logged in""" - super().__init__(status_code=403, detail="Login required") - - -class NotAuthorized(HTTPException): - def __init__(self): - """Raised when user is not privileged enough to do this action""" - super().__init__(status_code=403, detail="Not Authorized") diff --git a/backend/src/user/router.py b/backend/src/user/router.py index 3a6c0cc3..9635f2f1 100644 --- a/backend/src/user/router.py +++ b/backend/src/user/router.py @@ -1,73 +1,12 @@ -from fastapi.responses import HTMLResponse, RedirectResponse -from starlette.requests import Request from fastapi import APIRouter, Depends -from typing import Optional -from cas import CASClient -from src import config -from src.dependencies import get_db -from .schemas import User, UserCreate -from . import service +from .schemas import User from .dependencies import get_authenticated_user -from sqlalchemy.orm import Session -router = APIRouter() - -cas_client = CASClient( - version=2, - service_url=f"{config.CONFIG.api_url}/login?next=%2Fprofile", - server_url=f"{config.CONFIG.cas_server_url}", +router = APIRouter( + prefix="/api/user", tags=["user"], responses={404: {"description": "Not Found"}} ) -@router.get("/login", tags=["auth"]) -async def login(request: Request, next: Optional[str] = None, - ticket: Optional[str] = None, - db: Session = Depends(get_db)): - - if request.session.get("user", None): - # Already logged in - return RedirectResponse(request.url_for("profile")) - - if not ticket: - # No ticket, the request come from end user, send to CAS login - cas_login_url = cas_client.get_login_url() - return RedirectResponse(cas_login_url) - - user, attributes, _ = cas_client.verify_ticket(ticket) - - if not user or not attributes: - return HTMLResponse('Failed to verify ticket. Login') - else: # Login successfully, redirect according `next` query parameter. - - # Check if user exists in database, else create one. - if not await service.get_by_id(db, attributes["uid"]): - await service.create_user(db, UserCreate( - given_name=attributes["givenname"], - uid=attributes["uid"], - mail=attributes["mail"])) - - if not next: - return - response = RedirectResponse(next) - - request.session["user"] = dict(user=attributes["uid"]) - return response - - -@router.get("/profile", tags=["auth"], response_model=User) -async def profile(user: User = Depends(get_authenticated_user)): +@router.get("/profile", response_model=User) +async def profile(user=Depends(get_authenticated_user)): return user - - -@router.get("/logout", tags=["auth"]) -async def logout(request: Request): - redirect_url = request.url_for("logout_callback") - cas_logout_url = cas_client.get_logout_url(redirect_url) - return RedirectResponse(cas_logout_url) - - -@router.get("/logout_callback", tags=["auth"]) -async def logout_callback(request: Request): - # redirect from CAS logout request after CAS logout successfully - request.session.pop("user", None) - return HTMLResponse('Logged out from CAS. Login') diff --git a/frontend/.env.local.example b/frontend/.env.local.example new file mode 100644 index 00000000..930247ef --- /dev/null +++ b/frontend/.env.local.example @@ -0,0 +1,2 @@ +VITE_API_URL=http://localhost:5173 +VITE_APP_URL=https://localhost:8080 diff --git a/frontend/.gitignore b/frontend/.gitignore index 8ee54e8d..6fedd3b9 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -28,3 +28,6 @@ coverage *.sw? *.tsbuildinfo + +# Local SSL certificates +local-cert/ diff --git a/frontend/.prettierrc.json b/frontend/.prettierrc.json index cd12ad7d..c4ef772f 100644 --- a/frontend/.prettierrc.json +++ b/frontend/.prettierrc.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/prettierrc", "semi": true, "tabWidth": 4, - "singleQuote": true, + "singleQuote": false, "printWidth": 100, "trailingComma": "es5", "bracketSpacing": true, diff --git a/frontend/README.md b/frontend/README.md index 04bd4d6e..210623b9 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -4,8 +4,27 @@ npm install ``` +## .env.local file + +Linux: +```sh +cp .env.local.example .env.local +``` +Windows: +```sh +copy .env.local.example .env.local +``` + ### Compile and Hot-Reload for Development +> Note: For local development, an SSL-certificate is needed to interact with the +> CAS-server of UGent. Install [mkcert](https://github.com/FiloSottile/mkcert) +> and run +> ```sh +> mkdir local-cert +> mkcert -key-file local-cert/localhost-key.pem -cert-file local-cert/localhost.pem localhost +> ``` + ```sh npm run dev ``` diff --git a/frontend/env.d.ts b/frontend/env.d.ts deleted file mode 100644 index 11f02fe2..00000000 --- a/frontend/env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/frontend/package-lock.json b/frontend/package-lock.json index eb5c8554..000bdf66 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -27,6 +27,7 @@ "jsdom": "^24.0.0", "npm-run-all2": "^6.1.1", "prettier": "^3.0.3", + "sass": "^1.71.1", "typescript": "~5.3.0", "vite": "^5.0.11", "vitest": "^1.2.2", @@ -1526,6 +1527,19 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -1562,6 +1576,15 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/binary-extensions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -1653,6 +1676,42 @@ "node": "*" } }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2577,6 +2636,12 @@ "node": ">= 4" } }, + "node_modules/immutable": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.5.tgz", + "integrity": "sha512-8eabxkth9gZatlwl5TBuJnCsoTADlL6ftEr7A4qgdaTsPyreilDSnUk57SO+jfKcNtxPa22U5KK6DSeAYhpBJw==", + "dev": true + }, "node_modules/import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -2624,6 +2689,18 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "dev": true }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3094,6 +3171,15 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/npm-normalize-package-bin": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-3.0.1.tgz", @@ -3611,6 +3697,18 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", @@ -3760,6 +3858,23 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "dev": true }, + "node_modules/sass": { + "version": "1.71.1", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.71.1.tgz", + "integrity": "sha512-wovtnV2PxzteLlfNzbgm1tFXPLoZILYAMJtvoXXkD7/+1uP41eKkIt1ypWq5/q2uT94qHjXehEYfmjKOvjL9sg==", + "dev": true, + "dependencies": { + "chokidar": ">=3.0.0 <4.0.0", + "immutable": "^4.0.0", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index b0a17327..ea61c311 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -34,6 +34,7 @@ "jsdom": "^24.0.0", "npm-run-all2": "^6.1.1", "prettier": "^3.0.3", + "sass": "^1.71.1", "typescript": "~5.3.0", "vite": "^5.0.11", "vitest": "^1.2.2", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 8aba3c92..54c35ab7 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,82 +1,14 @@ - + diff --git a/frontend/src/assets/base.css b/frontend/src/assets/base.css deleted file mode 100644 index 8710b9ae..00000000 --- a/frontend/src/assets/base.css +++ /dev/null @@ -1,86 +0,0 @@ -/* color palette from */ -:root { - --vt-c-white: #ffffff; - --vt-c-white-soft: #f8f8f8; - --vt-c-white-mute: #f2f2f2; - - --vt-c-black: #181818; - --vt-c-black-soft: #222222; - --vt-c-black-mute: #282828; - - --vt-c-indigo: #2c3e50; - - --vt-c-divider-light-1: rgba(60, 60, 60, 0.29); - --vt-c-divider-light-2: rgba(60, 60, 60, 0.12); - --vt-c-divider-dark-1: rgba(84, 84, 84, 0.65); - --vt-c-divider-dark-2: rgba(84, 84, 84, 0.48); - - --vt-c-text-light-1: var(--vt-c-indigo); - --vt-c-text-light-2: rgba(60, 60, 60, 0.66); - --vt-c-text-dark-1: var(--vt-c-white); - --vt-c-text-dark-2: rgba(235, 235, 235, 0.64); -} - -/* semantic color variables for this project */ -:root { - --color-background: var(--vt-c-white); - --color-background-soft: var(--vt-c-white-soft); - --color-background-mute: var(--vt-c-white-mute); - - --color-border: var(--vt-c-divider-light-2); - --color-border-hover: var(--vt-c-divider-light-1); - - --color-heading: var(--vt-c-text-light-1); - --color-text: var(--vt-c-text-light-1); - - --section-gap: 160px; -} - -@media (prefers-color-scheme: dark) { - :root { - --color-background: var(--vt-c-black); - --color-background-soft: var(--vt-c-black-soft); - --color-background-mute: var(--vt-c-black-mute); - - --color-border: var(--vt-c-divider-dark-2); - --color-border-hover: var(--vt-c-divider-dark-1); - - --color-heading: var(--vt-c-text-dark-1); - --color-text: var(--vt-c-text-dark-2); - } -} - -*, -*::before, -*::after { - box-sizing: border-box; - margin: 0; - font-weight: normal; -} - -body { - min-height: 100vh; - color: var(--color-text); - background: var(--color-background); - transition: - color 0.5s, - background-color 0.5s; - line-height: 1.6; - font-family: - Inter, - -apple-system, - BlinkMacSystemFont, - 'Segoe UI', - Roboto, - Oxygen, - Ubuntu, - Cantarell, - 'Fira Sans', - 'Droid Sans', - 'Helvetica Neue', - sans-serif; - font-size: 15px; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} diff --git a/frontend/src/assets/base.scss b/frontend/src/assets/base.scss new file mode 100644 index 00000000..b03199b6 --- /dev/null +++ b/frontend/src/assets/base.scss @@ -0,0 +1,120 @@ +/* color palette from */ +// BUG: color-mod does not seem to work, fix this somehow +:root { + --color-primary: #1d357e; + --color-primary-light: color-mod(var(--color-primary) tint(15%)); + --color-primary-dark: color-mod(var(--color-primary) shade(15%)); + --color-primary-bg: color-mod(var(--color-primary) alpha(30)); + + --color-accent: #ffd200; + --color-accent-light: color-mod(var(--color-accent) tint(15%)); + --color-accent-dark: color-mod(var(--color-accent) shade(15%)); + --color-accent-bg: color-mod(var(--color-accent) alpha(30)); + + --black: #181818; + --gray-10: #222222; + --gray-8: #282828; + --gray-6: #444444; + --gray-4: #666666; + --gray-3: #b0b0b0; + --gray-2: #f2f2f2; + --gray-0: #f8f8f8; + --white: #ffffff; + + --color-success: #88c459; + --color-succes-secondary: color-mod(var(--color-succes) tint(15%)); + --color-error: #b00020; + --color-error-secondary: color-mod(var(--color-warning) tint(15%)); + --color-warning: #ffd137; + --color-warning-secondary: color-mod(var(--color-warning) tint(15%)); +} + +/* semantic color variables for this project */ +:root { + --color-text: var(--gray-10); + --color-text-on-primary: var(--white); + --color-text-on-accent: var(--gray-6); + --color-text-on-success: var(--white); + --color-text-on-error: var(--white); + --color-text-on-warning: var(--black); + --color-text-heading: var(--black); + --color-text-subtle: var(--gray-6); + --color-link: var(--color-primary); + --color-link-visited: var(--color-primary-dark); + --color-mark: var(--color-accent-bg); + --color-blockquote-border: var(--gray-2); + + --color-border: var(--gray-2); + --color-border-hover: var(--color-primary-light); + + --color-body: var(--white); + + --form-element-border: var(--color-border); + --form-element-border-focus: var(--color-border-hover); + --form-element-border-error: var(--color-error); + --form-element-bg: var(--white); + --form-text-placehoder: var(--gray-4); + + --btn-primary-bg: var(--color-primary); + --btn-primary-hover: var(--color-primary-light); + --btn-primary-active: var(--color-primary-dark); + --btn-primary-label: var(--white); + + --color-icon-primary: var(--gray-4); + --color-icon-secondary: inherit; + + --color-background: var(--white); + --color-background-soft: var(--gray-0); + --color-background-mute: var(--gray-2); + + --section-gap: 160px; +} + +@media (prefers-color-scheme: dark) { + :root { + --color-background: var(--gray-10); + --color-background-soft: var(--gray-8); + --color-background-mute: var(--gray-6); + + --color-border: var(--gray-6); + --color-border-hover: var(--gray-4); + + --color-heading: var(--white); + --color-text: var(--gray-0); + } +} + +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + font-weight: normal; +} + +body { + min-height: 100vh; + color: var(--color-text); + background: var(--color-background); + transition: + color 0.5s, + background-color 0.5s; + line-height: 1.6; + font-family: + Inter, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + Roboto, + Oxygen, + Ubuntu, + Cantarell, + "Fira Sans", + "Droid Sans", + "Helvetica Neue", + sans-serif; + font-size: 15px; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css deleted file mode 100644 index 2fe73bb6..00000000 --- a/frontend/src/assets/main.css +++ /dev/null @@ -1,35 +0,0 @@ -@import './base.css'; - -#app { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - font-weight: normal; -} - -a, -.green { - text-decoration: none; - color: hsla(160, 100%, 37%, 1); - transition: 0.4s; - padding: 3px; -} - -@media (hover: hover) { - a:hover { - background-color: hsla(160, 100%, 37%, 0.2); - } -} - -@media (min-width: 1024px) { - body { - display: flex; - place-items: center; - } - - #app { - display: grid; - grid-template-columns: 1fr 1fr; - padding: 0 2rem; - } -} diff --git a/frontend/src/assets/main.scss b/frontend/src/assets/main.scss new file mode 100644 index 00000000..4e7cd616 --- /dev/null +++ b/frontend/src/assets/main.scss @@ -0,0 +1,6 @@ +@import "./base.scss"; + +#app { + margin: 0 auto; + font-weight: normal; +} diff --git a/frontend/src/assets/universiteit-gent-logo-white.png b/frontend/src/assets/universiteit-gent-logo-white.png new file mode 100644 index 00000000..3c9bd56d Binary files /dev/null and b/frontend/src/assets/universiteit-gent-logo-white.png differ diff --git a/frontend/src/components/ApolloHeader.vue b/frontend/src/components/ApolloHeader.vue new file mode 100644 index 00000000..7ec8d81e --- /dev/null +++ b/frontend/src/components/ApolloHeader.vue @@ -0,0 +1,27 @@ + + + + + diff --git a/frontend/src/components/TheWelcome.vue b/frontend/src/components/TheWelcome.vue index 46cf1134..8cc8afd7 100644 --- a/frontend/src/components/TheWelcome.vue +++ b/frontend/src/components/TheWelcome.vue @@ -82,10 +82,10 @@ diff --git a/frontend/src/components/WelcomeItem.vue b/frontend/src/components/WelcomeItem.vue index 8325ed13..c45dee61 100644 --- a/frontend/src/components/WelcomeItem.vue +++ b/frontend/src/components/WelcomeItem.vue @@ -59,7 +59,7 @@ h3 { } .item:before { - content: ' '; + content: " "; border-left: 1px solid var(--color-border); position: absolute; left: 0; @@ -68,7 +68,7 @@ h3 { } .item:after { - content: ' '; + content: " "; border-left: 1px solid var(--color-border); position: absolute; left: 0; diff --git a/frontend/src/components/__tests__/HelloWorld.spec.ts b/frontend/src/components/__tests__/HelloWorld.spec.ts index a4490f08..79c1e3ae 100644 --- a/frontend/src/components/__tests__/HelloWorld.spec.ts +++ b/frontend/src/components/__tests__/HelloWorld.spec.ts @@ -1,11 +1,11 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect } from "vitest"; -import { mount } from '@vue/test-utils'; -import HelloWorld from '../HelloWorld.vue'; +import { mount } from "@vue/test-utils"; +import HelloWorld from "../HelloWorld.vue"; -describe('HelloWorld', () => { - it('renders properly', () => { - const wrapper = mount(HelloWorld, { props: { msg: 'Hello Vitest' } }); - expect(wrapper.text()).toContain('Hello Vitest'); +describe("HelloWorld", () => { + it("renders properly", () => { + const wrapper = mount(HelloWorld, { props: { msg: "Hello Vitest" } }); + expect(wrapper.text()).toContain("Hello Vitest"); }); }); diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 841d69f6..af449632 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -1,14 +1,14 @@ -import './assets/main.css'; +import "./assets/main.scss"; -import { createApp } from 'vue'; -import { createPinia } from 'pinia'; +import { createApp } from "vue"; +import { createPinia } from "pinia"; -import App from './App.vue'; -import router from './router'; +import App from "./App.vue"; +import router from "./router"; const app = createApp(App); app.use(createPinia()); app.use(router); -app.mount('#app'); +app.mount("#app"); diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 6a03024a..7015d323 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -1,28 +1,64 @@ -import { createRouter, createWebHistory } from 'vue-router'; -import HomeView from '@/views/HomeView.vue'; +import { useAuthStore } from "@/stores/auth-store"; +import { createRouter, createWebHistory } from "vue-router"; const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes: [ { - path: '/', - name: 'home', - component: HomeView, + path: "/", + redirect: { name: "home" }, }, { - path: '/about', - name: 'about', - // route level code-splitting - // this generates a separate chunk (About.[hash].js) for this route - // which is lazy-loaded when the route is visited. - component: () => import('../views/AboutView.vue'), + path: "/about", + name: "about", + component: () => import("../views/AboutView.vue"), }, { - path: '/login', - name: 'Login', - component: () => import('../views/LoginView.vue'), + path: "/login", + name: "login", + component: () => import("../views/LoginView.vue"), + beforeEnter: async (to, from, next) => { + const { isLoggedIn, login, setNext } = useAuthStore(); + if (isLoggedIn) { + router.replace("/home"); + next(); + } + const ticket = to.query.ticket?.toString(); + setNext(from.path); + const redirect = await login(ticket); + if (redirect) { + router.replace(redirect); + } + next(); + }, + meta: { + requiresAuth: false, + hideHeader: true, + }, + }, + { + path: "/home", + name: "home", + component: () => import("../views/UserView.vue"), + }, + { + path: "/:pathMatch(.*)", + name: "default", + component: () => import("../views/NotFoundView.vue"), + meta: { + requiresAuth: false, + }, }, ], }); +router.beforeEach(async (to, _, next) => { + const requiresAuth = to.meta.requiresAuth !== undefined ? to.meta.requiresAuth : true; + const { isLoggedIn } = useAuthStore(); + if (requiresAuth && !isLoggedIn) { + router.replace({ name: "login" }); + } + next(); +}); + export default router; diff --git a/frontend/src/stores/auth-store.ts b/frontend/src/stores/auth-store.ts new file mode 100644 index 00000000..4817f5c8 --- /dev/null +++ b/frontend/src/stores/auth-store.ts @@ -0,0 +1,63 @@ +import { defineStore } from "pinia"; +import { computed, ref } from "vue"; +import { useRouter } from "vue-router"; +import { useCASUrl } from "./cas-url"; + +interface Token { + token: string; + token_type: string; +} + +const apiUrl = import.meta.env.VITE_API_URL; + +export const useAuthStore = defineStore("auth", () => { + const storedToken = localStorage.getItem("token"); + const token = ref(storedToken ? JSON.parse(storedToken) : null); + const isLoggedIn = computed(() => token.value !== null && token.value !== undefined); + const { redirectUrl } = useCASUrl(); + const router = useRouter(); + // FIXME: after redirect to CAS server, value is reset -> use query parameter instead? + const next = ref("/home"); + + function setNext(url: string) { + next.value = url; + } + + async function login(ticket?: string): Promise { + if (isLoggedIn.value) { + return next.value; + } + if (ticket) { + try { + const response = await fetch(`${apiUrl}/api/token`, { + method: "POST", + body: JSON.stringify({ + returnUrl: redirectUrl, + ticket: ticket, + }), + headers: { "content-type": "application/json" }, + }); + if (!response.ok) { + throw new Error("Failed to verify ticket"); + } + const new_token = await response.json(); + token.value = new_token; + localStorage.setItem("token", JSON.stringify(token.value)); + return next.value; + } catch (e) { + router.replace({ query: { ticket: null } }); + alert("Failed to login"); + return null; + } + } + return null; + } + + function logout() { + token.value = null; + localStorage.removeItem("token"); + // FIXME: somehow does not work + router.replace("/"); + } + return { token, isLoggedIn, login, logout, setNext }; +}); diff --git a/frontend/src/stores/cas-url.ts b/frontend/src/stores/cas-url.ts new file mode 100644 index 00000000..b33143e2 --- /dev/null +++ b/frontend/src/stores/cas-url.ts @@ -0,0 +1,40 @@ +import { defineStore } from "pinia"; +import { ref, watch } from "vue"; + +interface Authority { + authority: string; + method: string; +} + +export const useCASUrl = defineStore("cas_url", () => { + const redirectUrl = ref("/login"); // TODO: this should not be a hardcoded ref, is registrated to CAS server + const CASUrl = ref(""); + + async function fetchAuthority(): Promise { + try { + const response = await fetch(`${import.meta.env.VITE_API_URL}/api/authority`); + if (!response.ok) { + throw new Error("Failed to fetch authority"); + } + return await response.json(); + } catch (error) { + console.error("Error fetching authority:", error); + return null; + } + } + + async function updateCASUrl() { + const authority = await fetchAuthority(); + if (!authority) { + return; + } + if (authority.method.toLowerCase() !== "cas") { + console.error("Authority is not a CAS server"); + return; + } + CASUrl.value = `${authority.authority}?service=${encodeURIComponent(`${import.meta.env.VITE_APP_URL}${redirectUrl.value}`)}`; + } + watch(redirectUrl, updateCASUrl, { immediate: true }); + + return { CASUrl, redirectUrl }; +}); diff --git a/frontend/src/stores/counter.ts b/frontend/src/stores/counter.ts deleted file mode 100644 index a4c345e0..00000000 --- a/frontend/src/stores/counter.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { ref, computed } from 'vue'; -import { defineStore } from 'pinia'; - -export const useCounterStore = defineStore('counter', () => { - const count = ref(0); - const doubleCount = computed(() => count.value * 2); - function increment() { - count.value++; - } - - return { count, doubleCount, increment }; -}); diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index a6ac7202..ce954f74 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -5,5 +5,5 @@ diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue index 750bab3e..7684b7ee 100644 --- a/frontend/src/views/LoginView.vue +++ b/frontend/src/views/LoginView.vue @@ -1,31 +1,34 @@ - + diff --git a/frontend/src/views/NotFoundView.vue b/frontend/src/views/NotFoundView.vue new file mode 100644 index 00000000..2f63c101 --- /dev/null +++ b/frontend/src/views/NotFoundView.vue @@ -0,0 +1,9 @@ + + + + + diff --git a/frontend/src/views/UserView.vue b/frontend/src/views/UserView.vue new file mode 100644 index 00000000..1f17a8b3 --- /dev/null +++ b/frontend/src/views/UserView.vue @@ -0,0 +1,63 @@ + + + + + diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json index e14c754d..b9410bd8 100644 --- a/frontend/tsconfig.app.json +++ b/frontend/tsconfig.app.json @@ -1,6 +1,7 @@ { "extends": "@vue/tsconfig/tsconfig.dom.json", - "include": ["env.d.ts", "src/**/*", "src/**/*.vue"], + "include": [ + "vite-env.d.ts", "src/**/*", "src/**/*.vue"], "exclude": ["src/**/__tests__/*"], "compilerOptions": { "composite": true, @@ -9,6 +10,11 @@ "baseUrl": ".", "paths": { "@/*": ["./src/*"] - } + }, + "typeRoots": [ + "./node_modules/@types/", + "./node_modules" + + ] } } diff --git a/frontend/vite-env.d.ts b/frontend/vite-env.d.ts new file mode 100644 index 00000000..2cc70612 --- /dev/null +++ b/frontend/vite-env.d.ts @@ -0,0 +1,9 @@ +/// +interface ImportMetaEnv { + readonly VITE_API_URL: string; + readonly VITE_APP_URL: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 5c45e1d9..372f1a88 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -2,15 +2,23 @@ import { fileURLToPath, URL } from 'node:url' import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' +import fs from "fs"; // https://vitejs.dev/config/ export default defineConfig({ - plugins: [ - vue(), - ], - resolve: { - alias: { - '@': fileURLToPath(new URL('./src', import.meta.url)) - } - } + plugins: [ + vue(), + ], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)) + } + }, + server: { + https: { + key: fs.readFileSync('./local-cert/localhost-key.pem'), + cert: fs.readFileSync('./local-cert/localhost.pem') + }, + port: 8080, + }, })