diff --git a/Backend/.env_example b/Backend/.env_example index ccca008f..73a55dc7 100644 --- a/Backend/.env_example +++ b/Backend/.env_example @@ -1,3 +1,4 @@ TEST_STAGE=local PASSWORD=SomePassword +SECRET_KEY=Some_random_32byte_key MONGODB_URL=mongodb://localhost:27017/ \ No newline at end of file diff --git a/Backend/app/api/v1/auth_api.py b/Backend/app/api/v1/auth_api.py new file mode 100644 index 00000000..2b91b669 --- /dev/null +++ b/Backend/app/api/v1/auth_api.py @@ -0,0 +1,70 @@ +import os +from dotenv import load_dotenv +from fastapi import APIRouter, Depends, HTTPException, Response +from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm +from app.repository.user_repository import UserRepository +from app.dependency.repository import get_user_repository +from datetime import datetime, timedelta +from jose import JWTError, jwt + +router = APIRouter() +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") + +load_dotenv() +SECRET_KEY = os.getenv("SECRET_KEY") +ALGORITHM = "HS256" + + +def create_access_token(data: dict, expires_delta: timedelta = None): + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=15) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + + +@router.post("/token") +async def login_for_access_token( + response: Response, + form_data: OAuth2PasswordRequestForm = Depends(), + user_repo: UserRepository = Depends(get_user_repository), +): + # check userdata + is_authenticated = user_repo.authenticate_user( + email=form_data.username, password=form_data.password + ) + if not is_authenticated: + raise HTTPException( + status_code=402, + detail="Incorrect email or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + # read user data + user_data = user_repo.read_users_by_email(email=form_data.username) + if not user_data: + raise HTTPException(status_code=404, detail="User not found") + + # take first element, because email_adresse should be unique + user = user_data[0] + + access_token_expires = timedelta(minutes=60) + access_token = create_access_token( + data={"sub": user["email_address"]}, expires_delta=access_token_expires + ) + response.set_cookie(key="access_token", value=access_token, httponly=True) + return {"access_token": access_token, "token_type": "bearer", "success": True} + + +@router.get("/verify-token") +async def verify_token(token: str = Depends(oauth2_scheme)): + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + email = payload.get("sub") + if email is None: + raise HTTPException(status_code=401, detail="Invalid token") + return {"email": email} + except JWTError: + raise HTTPException(status_code=401, detail="Invalid token") diff --git a/Backend/app/main.py b/Backend/app/main.py index e51427fb..5b1c8fa9 100644 --- a/Backend/app/main.py +++ b/Backend/app/main.py @@ -2,6 +2,8 @@ from fastapi.middleware.cors import CORSMiddleware from app.api.v1 import ticket_api +from app.api.v1 import auth_api + from app.dependency.collection import ( get_user_collection, get_service_collection, @@ -16,6 +18,7 @@ get_category_repository, get_location_repository, ) + from app.repository.user_repository import UserRepository from app.repository.service_repository import ServiceRepository from app.repository.location_repository import LocationRepository @@ -47,6 +50,9 @@ # Include the router from the text_endpoint module app.include_router(ticket_api.router, prefix="/api/v1") +# Include the router from the auth_endpoint module +app.include_router(auth_api.router, prefix="/api/v1") + @app.on_event("startup") async def startup_event(): diff --git a/Backend/app/repository/user_repository.py b/Backend/app/repository/user_repository.py index eec2dd42..b88a382d 100644 --- a/Backend/app/repository/user_repository.py +++ b/Backend/app/repository/user_repository.py @@ -32,3 +32,8 @@ def read_users_by_email(self, email: str) -> list[UserEntity]: logger.info(f"Reading user(s) with email {email} from the database...") users = list(self.collection.find({"email_address": email})) return users if users else [] + + def authenticate_user(self, email: str, password: str) -> bool: + logger.info(f"Authenticating user with email {email}...") + user = self.collection.find_one({"email_address": email, "password": password}) + return user is not None diff --git a/Backend/requirements.txt b/Backend/requirements.txt index 0847781c..05253dd3 100644 --- a/Backend/requirements.txt +++ b/Backend/requirements.txt @@ -13,4 +13,5 @@ fastapi-utils==0.2.1 requests==2.31.0 python-multipart==0.0.6 beautifulsoup4==4.12.2 -openai==1.7.2 \ No newline at end of file +openai==1.7.2 +python-jose==3.3.0 \ No newline at end of file diff --git a/Backend/test/api/v1/auth_api_test.py b/Backend/test/api/v1/auth_api_test.py new file mode 100644 index 00000000..11b357fc --- /dev/null +++ b/Backend/test/api/v1/auth_api_test.py @@ -0,0 +1,73 @@ +import os +from datetime import datetime, timedelta +from dotenv import load_dotenv +from jose import JWTError, jwt +import pytest +from fastapi.testclient import TestClient +from unittest.mock import Mock +from test.config.pytest import SKIP_TEST +from app.main import app +from app.dependency.repository import get_user_repository + + +load_dotenv() +SECRET_KEY = os.getenv("SECRET_KEY") + + +# Mock dependencies +@pytest.fixture +def mock_user_repository(): + user_repo = Mock() + user_repo.authenticate_user.return_value = True + user_repo.read_users_by_email.return_value = [{"email_address": "test@example.com"}] + return user_repo + + +@pytest.fixture +def client(mock_user_repository): + app.dependency_overrides[get_user_repository] = lambda: mock_user_repository + return TestClient(app) + + +def generate_valid_token(): + ALGORITHM = "HS256" + data = {"sub": "test@example.com"} + expire = datetime.utcnow() + timedelta(minutes=15) + to_encode = data.copy() + to_encode.update({"exp": expire}) + return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + + +@pytest.mark.skipif(condition=SKIP_TEST, reason=".env on git") +class TestAPI: + def test_login_for_access_token_success(self, client): + response = client.post( + "/api/v1/token", + data={"username": "user@example.com", "password": "password"}, + ) + assert response.status_code == 200 + assert "access_token" in response.json() + + def test_login_for_access_token_failure(self, client, mock_user_repository): + # Configure the mock to return False for authentication + mock_user_repository.authenticate_user.return_value = False + response = client.post( + "/api/v1/token", + data={"username": "wrong@example.com", "password": "wrongpassword"}, + ) + assert response.status_code == 402 + + def test_verify_token_success(self, client): + valid_token = generate_valid_token() + response = client.get( + "/api/v1/verify-token", headers={"Authorization": f"Bearer {valid_token}"} + ) + assert response.status_code == 200 + + def test_verify_token_failure(self, client): + # Use an altered valid token for this test + valid_token = generate_valid_token() + "invalid_part" + response = client.get( + "/api/v1/verify-token", headers={"Authorization": f"Bearer {valid_token}"} + ) + assert response.status_code == 401 diff --git a/Backend/test/repository/ticket_repository_test.py b/Backend/test/repository/ticket_repository_test.py index 63529f55..d2062ca2 100644 --- a/Backend/test/repository/ticket_repository_test.py +++ b/Backend/test/repository/ticket_repository_test.py @@ -33,6 +33,7 @@ def setUp(self): description="", priority=Prio.low, attachments=[], + requestType="", ) def test_create_ticket(self): @@ -98,6 +99,7 @@ def setUp(self): "description": "", "priority": Prio.low, "attachments": [], + "requestType": "", } def test_crud(self): diff --git a/Frontend/package-lock.json b/Frontend/package-lock.json index 19bc8c98..e2c9fc3b 100644 --- a/Frontend/package-lock.json +++ b/Frontend/package-lock.json @@ -19,6 +19,8 @@ "@angular/platform-browser-dynamic": "^16.2.0", "@angular/router": "^16.2.0", "cors": "^2.8.5", + "jsonwebtoken": "^9.0.2", + "jwt-decode": "^4.0.0", "rxjs": "~7.8.0", "stylelint": "^15.11.0", "tslib": "^2.3.0", @@ -29,6 +31,7 @@ "@angular/cli": "^16.2.9", "@angular/compiler-cli": "^16.2.0", "@types/jasmine": "~4.3.0", + "@types/jsonwebtoken": "^9.0.5", "@typescript-eslint/eslint-plugin": "^6.10.0", "@typescript-eslint/parser": "^6.10.0", "eslint": "^8.53.0", @@ -4556,6 +4559,15 @@ "integrity": "sha512-U3PUjAudAdJBeC2pgN8uTIKgxrb4nlDF3SF0++EldXQvQBGkpFZMSnwQiIoDU77tv45VgNkl/L4ouD+rEomujw==", "dev": true }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.5.tgz", + "integrity": "sha512-VRLSGzik+Unrup6BsouBeHsf4d1hOEgYWTm/7Nmw1sXoN1+tRly/Gy/po3yeahnP4jfnQWWAhQAqcNfH7ngOkA==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/mime": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.4.tgz", @@ -5803,6 +5815,11 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -7026,6 +7043,14 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "dev": true }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -8130,9 +8155,9 @@ "integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==" }, "node_modules/follow-redirects": { - "version": "1.15.3", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz", - "integrity": "sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==", + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz", + "integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==", "dev": true, "funding": [ { @@ -9652,6 +9677,54 @@ "node >= 0.2.0" ] }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "engines": { + "node": ">=18" + } + }, "node_modules/karma": { "version": "6.4.2", "resolved": "https://registry.npmjs.org/karma/-/karma-6.4.2.tgz", @@ -10020,12 +10093,47 @@ "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", "dev": true }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" + }, "node_modules/lodash.truncate": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", @@ -12925,7 +13033,6 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, "funding": [ { "type": "github", diff --git a/Frontend/package.json b/Frontend/package.json index 1e3b3e4c..7f27c2c7 100644 --- a/Frontend/package.json +++ b/Frontend/package.json @@ -23,6 +23,8 @@ "@angular/platform-browser-dynamic": "^16.2.0", "@angular/router": "^16.2.0", "cors": "^2.8.5", + "jsonwebtoken": "^9.0.2", + "jwt-decode": "^4.0.0", "rxjs": "~7.8.0", "stylelint": "^15.11.0", "tslib": "^2.3.0", @@ -33,6 +35,7 @@ "@angular/cli": "^16.2.9", "@angular/compiler-cli": "^16.2.0", "@types/jasmine": "~4.3.0", + "@types/jsonwebtoken": "^9.0.5", "@typescript-eslint/eslint-plugin": "^6.10.0", "@typescript-eslint/parser": "^6.10.0", "eslint": "^8.53.0", diff --git a/Frontend/src/app/app.component.css b/Frontend/src/app/app.component.css index 7e2673a2..904079fa 100644 --- a/Frontend/src/app/app.component.css +++ b/Frontend/src/app/app.component.css @@ -1,3 +1,13 @@ +body{ + display: flex; + flex-direction: column; +} + +.login-container { + align-self: flex-end; + margin-right: 20px; +} + .cards-container { display: flex; justify-content: space-between; diff --git a/Frontend/src/app/app.component.html b/Frontend/src/app/app.component.html index d733adc1..fe52f683 100644 --- a/Frontend/src/app/app.component.html +++ b/Frontend/src/app/app.component.html @@ -7,7 +7,7 @@ text-align: center; font-size: 5em; margin-top: 30px; - margin-bottom: 30px; + margin-bottom: 10px; margin-right: 80px; size: 45px; " @@ -15,6 +15,15 @@ {{ title }} +