diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..9340893 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,15 @@ +FROM mcr.microsoft.com/devcontainers/python:1-3.11-bullseye + +ENV PYTHONUNBUFFERED 1 + +# [Optional] If your requirements rarely change, uncomment this section to add them to the image. +# COPY requirements.txt /tmp/pip-tmp/ +# RUN pip3 --disable-pip-version-check --no-cache-dir install -r /tmp/pip-tmp/requirements.txt \ +# && rm -rf /tmp/pip-tmp + +# [Optional] Uncomment this section to install additional OS packages. +# RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ +# && apt-get -y install --no-install-recommends + + + diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..200a4e7 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,28 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/postgres +{ + "name": "Python 3 & PostgreSQL", + "dockerComposeFile": "docker-compose.yml", + "service": "app", + "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", + + // Features to add to the dev container. More info: https://containers.dev/features. + // "features": {}, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // This can be used to network with other containers or the host. + // "forwardPorts": [5000, 5432], + + // Use 'postCreateCommand' to run commands after the container is created. + "postCreateCommand": "pip install --user -r ./MediaManager/src/requirements.txt", + + // Configure tool-specific properties. + "customizations" : { + "jetbrains" : { + "backend" : "PyCharm" + } + }, + + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" +} diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml new file mode 100644 index 0000000..bf1e2ae --- /dev/null +++ b/.devcontainer/docker-compose.yml @@ -0,0 +1,35 @@ +version: '3.8' + +services: + app: + build: + context: .. + dockerfile: .devcontainer/Dockerfile + +# volumes: +# - ../..:/workspaces:cached + + # Overrides default command so things don't shut down after the process ends. + command: sleep infinity + + # Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function. + network_mode: service:db + + # Use "forwardPorts" in **devcontainer.json** to forward an app port locally. + # (Adding the "ports" property to this file will not forward from a Codespace.) + + db: + image: postgres:latest + restart: unless-stopped + volumes: + - postgres-data:/var/lib/postgresql/data + environment: + POSTGRES_USER: postgres + POSTGRES_DB: postgres + POSTGRES_PASSWORD: postgres + + # Add "forwardPorts": ["5432"] to **devcontainer.json** to forward PostgreSQL locally. + # (Adding the "ports" property to this file will not forward from a Codespace.) + +volumes: + postgres-data: \ No newline at end of file diff --git a/MediaManager/res/.env b/MediaManager/res/.env new file mode 100644 index 0000000..e69de29 diff --git a/MediaManager/src/MediaManager/main.py b/MediaManager/src/MediaManager/main.py deleted file mode 100644 index 6d7c6d9..0000000 --- a/MediaManager/src/MediaManager/main.py +++ /dev/null @@ -1,13 +0,0 @@ -from fastapi import FastAPI - -app = FastAPI() - - -@app.get("/") -async def root(): - return {"message": "Hello World"} - - -@app.get("/hello/{name}") -async def say_hello(name: str): - return {"message": f"Hello {name}"} diff --git a/MediaManager/src/auth/__init__.py b/MediaManager/src/auth/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/MediaManager/src/auth/__init__.py @@ -0,0 +1 @@ + diff --git a/MediaManager/src/auth/oidc.py b/MediaManager/src/auth/oidc.py new file mode 100644 index 0000000..ad60bed --- /dev/null +++ b/MediaManager/src/auth/oidc.py @@ -0,0 +1,9 @@ +from fastapi import Depends, APIRouter +from fastapi.security import OpenIdConnect + +oidc = OpenIdConnect(openIdConnectUrl="http://localhost:8080/realms/tools/.well-known/openid-configuration") +app = APIRouter() + +@app.get("/foo") +async def bar(token = Depends(oidc)): + return token \ No newline at end of file diff --git a/MediaManager/src/auth/password.py b/MediaManager/src/auth/password.py new file mode 100644 index 0000000..091b6b2 --- /dev/null +++ b/MediaManager/src/auth/password.py @@ -0,0 +1,110 @@ +from datetime import datetime, timedelta, timezone +from typing import Annotated + +import jwt +from fastapi import Depends, FastAPI, HTTPException, status, APIRouter +from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm +from jwt.exceptions import InvalidTokenError +from passlib.context import CryptContext +from pydantic import BaseModel + +import database +from database import UserInternal, User + +# to get a string like this run: +# openssl rand -hex 32 +SECRET_KEY = "09d25e094faa6ca2556c818166b7a9563b93f7099f6f0f4caa6cf63b88e8d3e7" +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 30 + + +fake_users_db = { + "johndoe": { + "username": "johndoe", + "full_name": "John Doe", + "email": "johndoe@example.com", + "hashed_password": "$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW", + "disabled": False, + } +} + + +class Token(BaseModel): + access_token: str + token_type: str + + +class TokenData(BaseModel): + username: str | None = None + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") + +app = APIRouter() + + +def verify_password(plain_password, hashed_password): + return pwd_context.verify(plain_password, hashed_password) + + +def get_password_hash(password): + return pwd_context.hash(password) + + +def authenticate_user(email: str, password: str) -> UserInternal: + user = database.get_user(email) + if not user: + return False + if not verify_password(password, user.hashed_password): + return False + return user + + +def create_access_token(data: dict, expires_delta: timedelta | None = None): + to_encode = data.copy() + if expires_delta: + expire = datetime.now(timezone.utc) + expires_delta + else: + expire = datetime.now(timezone.utc) + timedelta(minutes=15) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + + +async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]): + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + username: str = payload.get("sub") + if username is None: + raise credentials_exception + token_data = TokenData(username=username) + except InvalidTokenError: + raise credentials_exception + user = database.get_user(token_data.username) + if user is None: + raise credentials_exception + return user + +@app.post("/token") +async def login_for_access_token( + form_data: Annotated[OAuth2PasswordRequestForm, Depends()], +) -> Token: + print("post:",form_data.username, form_data.password) + user = authenticate_user(form_data.username, form_data.password) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + access_token = create_access_token( + data={"sub": user.username}, expires_delta=access_token_expires + ) + return Token(access_token=access_token, token_type="bearer") \ No newline at end of file diff --git a/MediaManager/src/database.py b/MediaManager/src/database.py new file mode 100644 index 0000000..a2d5639 --- /dev/null +++ b/MediaManager/src/database.py @@ -0,0 +1,114 @@ +import logging +import os +import sys +from abc import ABC, abstractmethod +from logging import getLogger +from uuid import uuid4 + +import psycopg +from pydantic import BaseModel + +log = getLogger(__name__) +log.addHandler(logging.StreamHandler(sys.stdout)) +log.level = logging.DEBUG + + +class User(BaseModel): + name: str + lastname: str + email: str + + +class UserInternal(User): + id: str = str(uuid4()) + hashed_password: str + + +class Database(ABC): + """ + Database context manager + """ + + def __init__(self, driver) -> None: + self.driver = driver + + @abstractmethod + def connect_to_database(self): + raise NotImplementedError() + + def __enter__(self): + self.connection = self.connect_to_database() + return self + + def __exit__(self, exception_type, exc_val, traceback): + self.connection.close() + + +class PgDatabase(Database): + """PostgreSQL Database context manager using psycopg""" + + def __init__(self) -> None: + self.driver = psycopg + super().__init__(self.driver) + + def connect_to_database(self): + return self.driver.connect( + host=os.getenv("DB_HOST"), + port=os.getenv("DB_PORT"), + user=os.getenv("DB_USERNAME"), + password=os.getenv("DB_PASSWORD"), + dbname=os.getenv("DB_NAME") + ) + + +def init_db(): + with PgDatabase() as db: + db.connection.execute(""" + CREATE TABLE IF NOT EXISTS users ( + id TEXT NOT NULL PRIMARY KEY, + lastname TEXT, + name TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + hashed_password TEXT NOT NULL + ); + """) + log.info("User Table initialized successfully") + + +def drop_tables() -> None: + with PgDatabase() as db: + db.connection.execute("DROP TABLE IF EXISTS users CASCADE;") + log.info("User Table dropped") + + +def create_user(user: UserInternal) -> bool: + with PgDatabase() as db: + try: + db.connection.execute( + """ + INSERT INTO users (id, name, lastname, email, hashed_password) + VALUES (%s, %s, %s, %s, %s) + """, + (user.id, user.name, user.lastname, user.email, user.hashed_password) + ) + except psycopg.errors.UniqueViolation: + return False + + log.info("User inserted successfully") + log.debug(f"User {user.model_dump()} created successfully") + return True + + +def get_user(email: str) -> UserInternal: + with PgDatabase() as db: + result = db.connection.execute( + "SELECT id, name, lastname, email, hashed_password FROM users WHERE email=%s", + (email,) + ).fetchone() + + if result is None: + return None + + user = UserInternal.model_construct(**dict(zip(["id", "name", "lastname", "email", "hashed_password"], result))) + log.debug(f"User {user.model_dump()} retrieved successfully") + return user diff --git a/MediaManager/src/main.py b/MediaManager/src/main.py new file mode 100644 index 0000000..98cb2b1 --- /dev/null +++ b/MediaManager/src/main.py @@ -0,0 +1,10 @@ +from fastapi import FastAPI + +import database +from routers import users +from auth import password + +app = FastAPI() +app.include_router(users.router, tags=["users"]) +app.include_router(password.app, tags=["authentication"]) +database.__init__() \ No newline at end of file diff --git a/MediaManager/src/requirements.txt b/MediaManager/src/requirements.txt new file mode 100644 index 0000000..e77be7d Binary files /dev/null and b/MediaManager/src/requirements.txt differ diff --git a/MediaManager/src/routers/__init__.py b/MediaManager/src/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/MediaManager/src/routers/users.py b/MediaManager/src/routers/users.py new file mode 100644 index 0000000..0999e54 --- /dev/null +++ b/MediaManager/src/routers/users.py @@ -0,0 +1,41 @@ +import logging + +from fastapi import APIRouter +from fastapi import Depends + +import database +from auth.password import authenticate_user, get_password_hash +from database import User, UserInternal + +router = APIRouter( + prefix="/users", +) + + +class CreateUser(User): + password: str + + +log = logging.getLogger(__file__) + + +@router.post("/", response_model=User) +async def create_user(user: CreateUser): + internal_user = UserInternal(name=user.name, lastname=user.lastname, email=user.email, + hashed_password=get_password_hash(user.password)) + database.create_user(internal_user) + return user + + +@router.get("/me/", response_model=User) +async def read_users_me( + current_user: User = Depends(authenticate_user), +): + return current_user + + +@router.get("/me/items/") +async def read_own_items( + current_user: User = Depends(authenticate_user), +): + return [{"item_id": "Foo", "owner": current_user.username}]