From 91f4291d9b90c626d412c1513767335c254f6611 Mon Sep 17 00:00:00 2001 From: swve Date: Sun, 26 Jun 2022 15:23:03 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=89=20first=20commit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 37 +++++++++- Dockerfile | 17 +++++ __init__.py | 0 app.py | 20 ++++++ config/__init__.py | 0 config/config.py | 0 config/config.yml | 0 docker-compose.yml | 25 +++++++ requirements.txt | 7 ++ src/__init__.py | 0 src/main.py | 18 +++++ src/routers/__init__.py | 0 src/routers/auth.py | 27 ++++++++ src/routers/houses.py | 49 +++++++++++++ src/routers/users.py | 51 ++++++++++++++ src/services/__init__.py | 0 src/services/auth.py | 64 +++++++++++++++++ src/services/database.py | 27 ++++++++ src/services/houses.py | 144 +++++++++++++++++++++++++++++++++++++++ src/services/security.py | 25 +++++++ src/services/users.py | 106 ++++++++++++++++++++++++++++ 21 files changed, 614 insertions(+), 3 deletions(-) create mode 100644 Dockerfile create mode 100644 __init__.py create mode 100644 app.py create mode 100644 config/__init__.py create mode 100644 config/config.py create mode 100644 config/config.yml create mode 100644 docker-compose.yml create mode 100644 requirements.txt create mode 100644 src/__init__.py create mode 100644 src/main.py create mode 100644 src/routers/__init__.py create mode 100644 src/routers/auth.py create mode 100644 src/routers/houses.py create mode 100644 src/routers/users.py create mode 100644 src/services/__init__.py create mode 100644 src/services/auth.py create mode 100644 src/services/database.py create mode 100644 src/services/houses.py create mode 100644 src/services/security.py create mode 100644 src/services/users.py diff --git a/.gitignore b/.gitignore index b6e47617..6769e21d 100644 --- a/.gitignore +++ b/.gitignore @@ -20,7 +20,6 @@ parts/ sdist/ var/ wheels/ -pip-wheel-metadata/ share/python-wheels/ *.egg-info/ .installed.cfg @@ -50,6 +49,7 @@ coverage.xml *.py,cover .hypothesis/ .pytest_cache/ +cover/ # Translations *.mo @@ -72,6 +72,7 @@ instance/ docs/_build/ # PyBuilder +.pybuilder/ target/ # Jupyter Notebook @@ -82,7 +83,9 @@ profile_default/ ipython_config.py # pyenv -.python-version +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. @@ -91,7 +94,22 @@ ipython_config.py # install all needed dependencies. #Pipfile.lock -# PEP 582; used by e.g. github.com/David-OConnor/pyflow +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm __pypackages__/ # Celery stuff @@ -127,3 +145,16 @@ dmypy.json # Pyre type checker .pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..27972cb3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +# +FROM python:3.10.5 + +# +WORKDIR /usr/learnhouse + +# +COPY ./requirements.txt /usr/learnhouse/requirements.txt + +# +RUN pip install --no-cache-dir --upgrade -r /usr/learnhouse/requirements.txt + +# +COPY ./ /usr/learnhouse + +# +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "80" , "--reload"] diff --git a/__init__.py b/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app.py b/app.py new file mode 100644 index 00000000..b1cc30d3 --- /dev/null +++ b/app.py @@ -0,0 +1,20 @@ +from typing import Union +from fastapi import FastAPI +from src import main +from fastapi.staticfiles import StaticFiles +from src.main import global_router +import pymongo + +# Init +app = FastAPI( + title="LearnHouse", + description="LearnHouse is a new open-source platform tailored for learning experiences.", + version="0.1.0", + root_path="/" +) + +app.include_router(global_router) + +@app.get("/") +async def root(): + return {"Message": "Welcome to LearnHouse ✨"} \ No newline at end of file diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/config/config.py b/config/config.py new file mode 100644 index 00000000..e69de29b diff --git a/config/config.yml b/config/config.yml new file mode 100644 index 00000000..e69de29b diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..1379f716 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,25 @@ +version: "3.9" +services: + api: + build: . + ports: + - "1338:80" + volumes: + - .:/usr/learnhouse + mongo: + image: mongo:5.0 + restart: always + ports: + - "27017:27017" + environment: + - MONGO_INITDB_ROOT_USERNAME=learnhouse + - MONGO_INITDB_ROOT_PASSWORD=learnhouse + mongo-express: + image: mongo-express + restart: always + ports: + - 8081:8081 + environment: + ME_CONFIG_MONGODB_ADMINUSERNAME: learnhouse + ME_CONFIG_MONGODB_ADMINPASSWORD: learnhouse + ME_CONFIG_MONGODB_URL: mongodb://learnhouse:learnhouse@mongo:27017/ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..996a820d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +fastapi==0.78.0 +pydantic>=1.8.0,<2.0.0 +uvicorn>=0.15.0,<0.16.0 +pymongo==4.1.1 +python-multipart +python-jose +passlib diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/main.py b/src/main.py new file mode 100644 index 00000000..138c8f90 --- /dev/null +++ b/src/main.py @@ -0,0 +1,18 @@ +from .routers import users +from fastapi import APIRouter +from .routers import users, auth, houses +from starlette.responses import FileResponse + + +global_router = APIRouter(prefix="/api") + + +## API Routes +global_router.include_router(users.router, prefix="/users", tags=["users"]) +global_router.include_router(auth.router, prefix="/auth", tags=["auth"]) +global_router.include_router(houses.router, prefix="/houses", tags=["houses"]) + + + + + diff --git a/src/routers/__init__.py b/src/routers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/routers/auth.py b/src/routers/auth.py new file mode 100644 index 00000000..c085547d --- /dev/null +++ b/src/routers/auth.py @@ -0,0 +1,27 @@ +from fastapi import Depends, FastAPI, APIRouter, HTTPException, status +from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm +from pydantic import BaseModel +from ..services.auth import * +from ..services.users import * +from datetime import datetime, timedelta + +router = APIRouter() + + +@router.post("/token", response_model=Token) +async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends()): + """ + OAuth2 compatible token login, get access token for future requests + """ + user = await 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 {"access_token": access_token, "token_type": "bearer"} diff --git a/src/routers/houses.py b/src/routers/houses.py new file mode 100644 index 00000000..3ba509c6 --- /dev/null +++ b/src/routers/houses.py @@ -0,0 +1,49 @@ +from fastapi import APIRouter, Depends +from src.services.auth import get_current_user + +from src.services.houses import House, HouseInDB, create_house, get_house, get_houses, update_house, delete_house +from src.services.users import User + + +router = APIRouter() + + +@router.post("/") +async def api_create_house(house_object: House, current_user: User = Depends(get_current_user)): + """ + Create new house + """ + return await create_house(house_object, current_user) + + +@router.get("/{house_id}") +async def api_get_house(house_id: str): + """ + Get single House by house_id + """ + return await get_house(house_id) + + +@router.get("/page/{house_id}/limit/{limit}") +async def api_get_house_by(page: int, limit: int): + """ + Get houses by page and limit + """ + return await get_houses(page, limit) + + +@router.put("/{house_id}") +async def api_update_house(house_object: House, house_id: str, current_user: User = Depends(get_current_user)): + """ + Update House by house_id + """ + return await update_house(house_object, house_id, current_user) + + +@router.delete("/{house_id}") +async def api_delete_house(house_id: str, current_user: User = Depends(get_current_user)): + """ + Delete House by ID + """ + + return await delete_house(house_id, current_user) diff --git a/src/routers/users.py b/src/routers/users.py new file mode 100644 index 00000000..612177fd --- /dev/null +++ b/src/routers/users.py @@ -0,0 +1,51 @@ +from fastapi import Depends, FastAPI, APIRouter +from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm +from pydantic import BaseModel +from ..services.auth import * +from ..services.users import * + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/token") + + +router = APIRouter() + + +@router.get("/me") +async def api_get_current_user(current_user: User = Depends(get_current_user)): + """ + Get current user + """ + return current_user.dict() + + +@router.get("/username/{username}") +async def api_get_user_by_username(username: str): + """ + Get single user by username + """ + return await get_user(username) + + +@router.post("/") +async def api_create_user(user_object: UserInDB): + """ + Create new user + """ + return await create_user(user_object) + + +@router.delete("/username/{username}") +async def api_delete_user(username: str): + """ + Delete user by ID + """ + + return await delete_user(username) + + +@router.put("/username/{username}") +async def api_update_user(user_object: UserInDB): + """ + Update user by ID + """ + return await update_user(user_object) diff --git a/src/services/__init__.py b/src/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/services/auth.py b/src/services/auth.py new file mode 100644 index 00000000..2275ea7b --- /dev/null +++ b/src/services/auth.py @@ -0,0 +1,64 @@ +from pydantic import BaseModel +from fastapi import Depends, FastAPI, APIRouter, HTTPException, status +from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm +from passlib.context import CryptContext +from jose import JWTError, jwt +from datetime import datetime, timedelta +from ..services.users import * +from ..services.security import * + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/token") + +#### Classes #################################################### + + +class Token(BaseModel): + access_token: str + token_type: str + + +class TokenData(BaseModel): + username: str | None = None + +#### Classes #################################################### + + + +async def authenticate_user(username: str, password: str): + user = await security_get_user(username) + if not user: + return False + if not await security_verify_password(password, user.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.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 + + +async def get_current_user(token: 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 JWTError: + raise credentials_exception + user = await get_user(username=token_data.username) + if user is None: + raise credentials_exception + return User(**user.dict()) diff --git a/src/services/database.py b/src/services/database.py new file mode 100644 index 00000000..5b626b4c --- /dev/null +++ b/src/services/database.py @@ -0,0 +1,27 @@ +import pymongo + +# MongoDB +client = pymongo.MongoClient("mongodb://learnhouse:learnhouse@mongo:27017/") +learnhouseDB = client["learnhouse"] + + +async def create_database(): + learnhouseDB = client["learnhouse"] + + +async def check_database(): + # Check if database learnhouse exists + + if "learnhouse" in client.list_database_names(): + return True + else: + create_database() + + +async def create_config_collection(): + # Create config collection if it doesn't exist + + learnhouseDB = client["learnhouse"] + config = learnhouseDB["config"] + config.insert_one({"name": "LearnHouse", "date": "2022"}) + return config.find_one() diff --git a/src/services/houses.py b/src/services/houses.py new file mode 100644 index 00000000..edca4128 --- /dev/null +++ b/src/services/houses.py @@ -0,0 +1,144 @@ +import json +from typing import List +from uuid import uuid4 +from pydantic import BaseModel +from src.services.users import User +from ..services.database import create_config_collection, check_database, create_database, learnhouseDB, learnhouseDB +from ..services.security import * +from fastapi import FastAPI, HTTPException, status, Request, Response, BackgroundTasks +from datetime import datetime + +#### Classes #################################################### + + +class House(BaseModel): + name: str + photo: str + description: str + email: str + org: str + + +class HouseInDB(House): + house_id: str + owners: List[str] + +#### Classes #################################################### + + +async def get_house(house_id: str): + await check_database() + houses = learnhouseDB["houses"] + + house = houses.find_one({"house_id": house_id}) + + if not house: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="House does not exist") + + house = House(**house) + return house + + +async def create_house(house_object: House, current_user: User): + await check_database() + houses = learnhouseDB["houses"] + + # find if house already exists using name + isHouseAvailable = houses.find_one({"name": house_object.name}) + + if isHouseAvailable: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="House name already exists") + + # generate house_id with uuid4 + house_id = str(f"house_{uuid4()}") + + house = HouseInDB(house_id=house_id, owners=[ + current_user.username], **house_object.dict()) + + house_in_db = houses.insert_one(house.dict()) + + if not house_in_db: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Unavailable database") + + return house.dict() + + +async def update_house(house_object: House, house_id: str, current_user: User): + await check_database() + + # verify house rights + await verify_house_ownership(house_id, current_user) + + houses = learnhouseDB["houses"] + + house = houses.find_one({"house_id": house_id}) + + ## get owner value from house object database + owners = house["owners"] + + if not house: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="House does not exist") + + updated_house = HouseInDB(house_id=house_id, owners=owners, **house_object.dict()) + + houses.update_one({"house_id": house_id}, {"$set": updated_house.dict()}) + + return HouseInDB(**updated_house.dict()) + + +async def delete_house(house_id: str, current_user: User): + await check_database() + + # verify house rights + await verify_house_ownership(house_id, current_user) + + houses = learnhouseDB["houses"] + + house = houses.find_one({"house_id": house_id}) + + if not house: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="House does not exist") + + isDeleted = houses.delete_one({"house_id": house_id}) + + if isDeleted: + return {"detail": "House deleted"} + else: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Unavailable database") + + +async def get_houses(page: int = 1, limit: int = 10): + await check_database() + houses = learnhouseDB["houses"] + + # get all houses from database + all_houses = houses.find().sort("name", 1).skip(10 * (page - 1)).limit(limit) + + return [json.loads(json.dumps(house, default=str)) for house in all_houses] + + +#### Security #################################################### + +async def verify_house_ownership(house_id: str, current_user: User): + await check_database() + houses = learnhouseDB["houses"] + + house = houses.find_one({"house_id": house_id}) + + if not house: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="House does not exist") + + if current_user.username not in house["owners"]: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="User does not own this house") + + return True + +#### Security #################################################### diff --git a/src/services/security.py b/src/services/security.py new file mode 100644 index 00000000..20aa877e --- /dev/null +++ b/src/services/security.py @@ -0,0 +1,25 @@ +from passlib.context import CryptContext +from jose import JWTError, jwt +from passlib.hash import pbkdf2_sha256 + +### 🔒 JWT ############################################################## + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +ACCESS_TOKEN_EXPIRE_MINUTES = 30 +SECRET_KEY = "09d25e094faa6ca2556c818166b7a9563b93f7099f6f0f4caa6cf63b88e8d3e7" +ALGORITHM = "HS256" + +### 🔒 JWT ############################################################## + + +### 🔒 Passwords Hashing ############################################################## + +async def security_hash_password(password: str): + return pbkdf2_sha256.hash(password) + + +async def security_verify_password(plain_password: str, hashed_password: str): + return pbkdf2_sha256.verify(plain_password, hashed_password) + +### 🔒 Passwords Hashing ############################################################## diff --git a/src/services/users.py b/src/services/users.py new file mode 100644 index 00000000..7aa298c6 --- /dev/null +++ b/src/services/users.py @@ -0,0 +1,106 @@ +from pydantic import BaseModel +from ..services.database import create_config_collection, check_database, create_database, learnhouseDB, learnhouseDB +from ..services.security import * +from fastapi import FastAPI, HTTPException, status, Request, Response, BackgroundTasks +from datetime import datetime + +#### Classes #################################################### + + +class User(BaseModel): + username: str + email: str + full_name: str | None = None + disabled: bool | None = None + avatar_url: str | None = None + verified: bool + created_date: str + bio : str | None = None + + +class UserInDB(User): + password: str + +#### Classes #################################################### + + +async def get_user(username: str): + check_database() + users = learnhouseDB["users"] + + user = users.find_one({"username": username}) + + if not user: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="User does not exist") + + user = User(**user) + return user + + +async def security_get_user(username: str): + check_database() + users = learnhouseDB["users"] + + user = users.find_one({"username": username}) + + if not user: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="User does not exist") + + return UserInDB(**user) + + +async def update_user(user_object: UserInDB): + check_database() + users = learnhouseDB["users"] + + isUserAvailable = users.find_one({"username": user_object.username}) + + if not isUserAvailable: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="User does not exist") + + user_object.password = security_hash_password(user_object.password) + + updated_user = {"$set": user_object.dict()} + users.update_one({"username": user_object.username}, updated_user) + + return User(**user_object.dict()) + + +async def delete_user(username: str): + check_database() + users = learnhouseDB["users"] + + isUserAvailable = users.find_one({"username": username}) + + if not isUserAvailable: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="User does not exist") + + users.delete_one({"username": username}) + + return {"detail": "User deleted"} + + +async def create_user(user_object: UserInDB): + check_database() + users = learnhouseDB["users"] + + isUserAvailable = users.find_one({"username": user_object.username}) + + if isUserAvailable: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="User already exists") + + # lowercase username + user_object.username = user_object.username.lower() + + user_object.created_date = str(datetime.now()) + + user_object.password = await security_hash_password(user_object.password) + + users.insert_one(user_object.dict()) + + return User(**user_object.dict())