diff --git a/MediaManager/src/auth/__init__.py b/MediaManager/src/auth/__init__.py index 8b13789..a47b0ca 100644 --- a/MediaManager/src/auth/__init__.py +++ b/MediaManager/src/auth/__init__.py @@ -1 +1,69 @@ +import logging +from datetime import datetime, timedelta, timezone +from typing import Annotated +import jwt +from fastapi import Depends, HTTPException, status, APIRouter +from fastapi.security import OAuth2, OAuth2AuthorizationCodeBearer +from jwt.exceptions import InvalidTokenError +from pydantic import BaseModel +import database +from database import UserInternal + + +class Token(BaseModel): + access_token: str + token_type: str + + +class TokenData(BaseModel): + uid: str | None = None + + +# to get a string like this run: +# openssl rand -hex 32 +# TODO: remove secrets from files +SECRET_KEY = "09d25e094faa6ca2556c818166b7a9563b93f7099f6f0f4caa6cf63b88e8d3e7" +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 30 + +log = logging.getLogger(__name__) +log.level = logging.DEBUG +log.addHandler(logging.StreamHandler()) + +router = APIRouter() + +async def get_current_user(token: str) -> UserInternal: + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + log.debug("token: "+ token) + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + log.debug("jwt payload: "+payload.__str__()) + user_uid: str = payload.get("sub") + log.debug("jwt payload sub (user uid): "+user_uid) + if user_uid is None: + raise credentials_exception + token_data = TokenData(uid=user_uid) + except InvalidTokenError: + log.warning("received invalid token: "+token) + raise credentials_exception + user = database.get_user(uid=token_data.uid) + if user is None: + log.debug("user not found") + raise credentials_exception + log.debug("received user: "+user.__str__()) + 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 diff --git a/MediaManager/src/auth/oidc.py b/MediaManager/src/auth/oidc.py index ad60bed..7bedf87 100644 --- a/MediaManager/src/auth/oidc.py +++ b/MediaManager/src/auth/oidc.py @@ -1,9 +1,37 @@ +from os import environ + from fastapi import Depends, APIRouter -from fastapi.security import OpenIdConnect +from fastapi.openapi.models import OAuthFlows, OAuthFlowAuthorizationCode +from fastapi.security import OpenIdConnect, OAuth2AuthorizationCodeBearer +from pydantic import BaseModel + +from auth import router + + +#TODO: Implement OAuth2/Open ID Connect +class Settings(BaseModel): + OAUTH2_AUTHORIZATION_URL: str + OAUTH2_TOKEN_URL: str + OAUTH2_SCOPE: str + + @property + def oauth2_flows(self) -> OAuthFlows: + return OAuthFlows( + authorizationCode=OAuthFlowAuthorizationCode( + authorizationUrl=self.OAUTH2_AUTHORIZATION_URL, + tokenUrl=self.OAUTH2_TOKEN_URL, + scopes={self.OAUTH2_SCOPE: "Access to this API"}, + ), + ) + + +oauth2 = OAuth2AuthorizationCodeBearer( + authorizationUrl="/authorize", + tokenUrl="/token", +) + +@router.get("/foo") +async def bar(token = Depends()): + return token -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 index 0e805ec..b79e648 100644 --- a/MediaManager/src/auth/password.py +++ b/MediaManager/src/auth/password.py @@ -1,45 +1,16 @@ -from datetime import datetime, timedelta, timezone +from datetime import timedelta from typing import Annotated import bcrypt -import jwt from fastapi import Depends, HTTPException, status, APIRouter from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm -from jwt.exceptions import InvalidTokenError -from pydantic import BaseModel import database +from auth import ACCESS_TOKEN_EXPIRE_MINUTES, create_access_token, Token, router from database import UserInternal -# 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 - - oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") -app = APIRouter() def verify_password(plain_password, hashed_password): @@ -63,46 +34,15 @@ def authenticate_user(email: str, password: str) -> bool | UserInternal: :param password: password of the user :return: if authentication succeeds, returns the user object with added name and lastname, otherwise or if the user doesn't exist returns False """ - user = database.get_user(email) + user = database.get_user(email=email) if not user: return False if not verify_password(password, user.hashed_password): return False - return True - - -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") +@router.post("/token") async def login_for_access_token( form_data: Annotated[OAuth2PasswordRequestForm, Depends()], ) -> Token: @@ -111,11 +51,11 @@ async def login_for_access_token( if not user: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Incorrect username or password", + detail="Incorrect email or password", headers={"WWW-Authenticate": "Bearer"}, ) access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) access_token = create_access_token( - data={"sub": user.email}, expires_delta=access_token_expires + data={"sub": user.id}, expires_delta=access_token_expires ) return Token(access_token=access_token, token_type="bearer") diff --git a/MediaManager/src/database.py b/MediaManager/src/database.py index 5ef3323..9123af3 100644 --- a/MediaManager/src/database.py +++ b/MediaManager/src/database.py @@ -5,10 +5,13 @@ from logging import getLogger from uuid import uuid4 import psycopg +from annotated_types.test_cases import cases +from psycopg.rows import dict_row from pydantic import BaseModel log = getLogger(__name__) log.level = logging.DEBUG +log.addHandler(logging.StreamHandler()) class User(BaseModel): @@ -62,9 +65,11 @@ class PgDatabase(Database): port=os.getenv("DB_PORT"), user=os.getenv("DB_USERNAME"), password=os.getenv("DB_PASSWORD"), - dbname=os.getenv("DB_NAME") + dbname=os.getenv("DB_NAME"), + row_factory=dict_row ) + def init_db(): with PgDatabase() as db: db.connection.execute(""" @@ -92,38 +97,46 @@ def create_user(user: UserInternal) -> bool: :return: True if user was created, False otherwise """ 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 as e: - log.error(e) - return False + 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 as e: + log.error(e) + return False log.info("User inserted successfully") - log.debug(f"Inserted following User:", user.model_dump()) + log.debug(f"Inserted following User: "+ user.model_dump()) return True -def get_user(email: str) -> UserInternal | None: +def get_user(email: str = None, uid: str = None) -> UserInternal | None: """ + either specify the email or uid to search for the user, if both parameters are provided the uid will be used + :param email: the users email address - :return: if user was found its is returned, otherwise None + :param uid: the users id + :return: if user was found it a UserInternal object is returned, otherwise None """ with PgDatabase() as db: - result = db.connection.execute( - "SELECT id, name, lastname, email, hashed_password FROM users WHERE email=%s", - (email,) - ).fetchone() + if email is not None and uid is None: + result = db.connection.execute( + "SELECT id, name, lastname, email, hashed_password FROM users WHERE email=%s", + (email,) + ).fetchone() + if uid is not None: + result = db.connection.execute( + "SELECT id, name, lastname, email, hashed_password FROM users WHERE id=%s", + (uid,) + ).fetchone() if result is None: return None - - user = UserInternal.model_construct(**dict(zip(["id", "name", "lastname", "email", "hashed_password"], result))) + user = UserInternal(id = result["id"], name = result["name"], lastname = result["lastname"], email = result["email"], hashed_password = result["hashed_password"]) log.debug(f"Retrieved User succesfully: {user.model_dump()} ") return user diff --git a/MediaManager/src/main.py b/MediaManager/src/main.py index 2d469d1..4b468bc 100644 --- a/MediaManager/src/main.py +++ b/MediaManager/src/main.py @@ -8,8 +8,7 @@ from auth import password app = FastAPI() app.include_router(users.router, tags=["users"]) -app.include_router(password.app, tags=["authentication"]) - +app.include_router(password.router, tags=["authentication"]) if __name__ == "__main__": diff --git a/MediaManager/src/routers/__init__.py b/MediaManager/src/routers/__init__.py index e69de29..c3e9a82 100644 --- a/MediaManager/src/routers/__init__.py +++ b/MediaManager/src/routers/__init__.py @@ -0,0 +1,4 @@ +import logging + +log = logging.getLogger(__name__) +log.level = logging.DEBUG \ No newline at end of file diff --git a/MediaManager/src/routers/users.py b/MediaManager/src/routers/users.py index eea2f12..4e0e062 100644 --- a/MediaManager/src/routers/users.py +++ b/MediaManager/src/routers/users.py @@ -6,8 +6,10 @@ from pydantic import BaseModel from starlette.responses import JSONResponse import database -from auth.password import authenticate_user, get_password_hash +from auth import get_current_user +from auth.password import get_password_hash from database import User, UserInternal +from routers import log router = APIRouter( prefix="/users", @@ -23,9 +25,6 @@ class CreateUser(User): """ password: str -log = logging.getLogger(__name__) -log.level = logging.DEBUG - @router.post("/",status_code=201, responses = { 409: {"model": Message, "description": "User with provided email already exists"}, 201:{"model": UserInternal, "description": "User created successfully"} @@ -44,15 +43,15 @@ async def create_user( -@router.get("/me", response_model=User) +@router.get("/me") async def read_users_me( - current_user: User = Depends(authenticate_user), + current_user: UserInternal = Depends(get_current_user), ): - return current_user + return JSONResponse(status_code=200, content=current_user.model_dump()) @router.get("/me/items") async def read_own_items( - current_user: User = Depends(authenticate_user), + current_user: User = Depends(get_current_user), ): - return [{"item_id": "Foo", "owner": current_user.username}] + return [{"item_id": "Foo", "owner": current_user.name}]