From 05c8690c03248dff55fcf59c0788d2d8c7745971 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Mon, 11 Oct 2021 20:51:53 +0000 Subject: [PATCH 1/2] Basic backed auth and DB --- .gitignore | 141 ++++++++++++++++++ dashboard_backend/maia_dash_api/__init__.py | 4 + .../maia_dash_api/auth/__init__.py | 3 + .../maia_dash_api/auth/auth_db.py | 74 +++++++++ .../maia_dash_api/auth/auth_router.py | 54 +++++++ .../maia_dash_api/auth/auth_schemas.py | 31 ++++ .../maia_dash_api/auth/jwt_management.py | 63 ++++++++ dashboard_backend/maia_dash_api/auth/oauth.py | 96 ++++++++++++ .../maia_dash_api/database/__init__.py | 1 + .../maia_dash_api/database/db_client.py | 27 ++++ dashboard_backend/main.py | 50 +++++++ 11 files changed, 544 insertions(+) create mode 100644 dashboard_backend/maia_dash_api/__init__.py create mode 100644 dashboard_backend/maia_dash_api/auth/__init__.py create mode 100644 dashboard_backend/maia_dash_api/auth/auth_db.py create mode 100644 dashboard_backend/maia_dash_api/auth/auth_router.py create mode 100644 dashboard_backend/maia_dash_api/auth/auth_schemas.py create mode 100644 dashboard_backend/maia_dash_api/auth/jwt_management.py create mode 100644 dashboard_backend/maia_dash_api/auth/oauth.py create mode 100644 dashboard_backend/maia_dash_api/database/__init__.py create mode 100644 dashboard_backend/maia_dash_api/database/db_client.py create mode 100644 dashboard_backend/main.py diff --git a/.gitignore b/.gitignore index e80b510..c9a7266 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,144 @@ node_modules .idea package-lock.json mongo_data +.vscode/ + +#Python default gitignore +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# 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. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ diff --git a/dashboard_backend/maia_dash_api/__init__.py b/dashboard_backend/maia_dash_api/__init__.py new file mode 100644 index 0000000..369d430 --- /dev/null +++ b/dashboard_backend/maia_dash_api/__init__.py @@ -0,0 +1,4 @@ +from .auth import * +from .database import * + +__version__ = '0.0.1' diff --git a/dashboard_backend/maia_dash_api/auth/__init__.py b/dashboard_backend/maia_dash_api/auth/__init__.py new file mode 100644 index 0000000..e1bac43 --- /dev/null +++ b/dashboard_backend/maia_dash_api/auth/__init__.py @@ -0,0 +1,3 @@ +from .oauth import auth_via_lichess, login_via_lichess +from .auth_router import auth_router, jwt_contr +from .auth_schemas import * diff --git a/dashboard_backend/maia_dash_api/auth/auth_db.py b/dashboard_backend/maia_dash_api/auth/auth_db.py new file mode 100644 index 0000000..f173b6a --- /dev/null +++ b/dashboard_backend/maia_dash_api/auth/auth_db.py @@ -0,0 +1,74 @@ +import datetime + +from ..database import get_dash_db + +default_username = "dash-guest" + + +async def get_user_info(user_id): + client = await get_dash_db() + return await client.users.find_one({"_id": user_id}) + + +async def log_auth_event(client, event_dict): + event_dict.update({"event_date": datetime.datetime.now()}) + await client.auth_events.insert_one(event_dict) + +async def set_lichess_username(user_id, user_name): + client = await get_dash_db() + await client.users.update_one( + {"_id": user_id}, + {"$set": {"lichess_username": user_name}}, + ) + return await client.users.find_one({"_id": user_id}) + +async def register_user_db(user_id): + client = await get_db() + current_dt = datetime.datetime.now() + result = await client.users.insert_one( + { + "_id": user_id, + "user_id": user_id, + "provided_username": default_username, + "lichess_username": None, + "creation_date": current_dt, + } + ) + return result + + +async def log_user_login(user_id, how, screen_width=None, screen_height=None): + client = await get_dash_db() + await log_auth_event( + client, + { + "user_id": user_id, + "how": how, + "screen_width": screen_width, + "screen_height": screen_height, + }, + ) + + +async def log_player_data(user_dict): + client = await get_dash_db() + await client["user_data"].insert_one(user_dict) + + +async def set_lichess_username(user_id, username): + client = await get_dash_db() + update_result = client.users.update_one( + {"_id": user_id}, + {"$set": {"lichess_username": username}}, + ) + await log_auth_event( + client, + { + "user_id": user_id, + "event_type": "set_username", + "lichess_name": True, + "username": username, + }, + ) + await update_result + return user_id diff --git a/dashboard_backend/maia_dash_api/auth/auth_router.py b/dashboard_backend/maia_dash_api/auth/auth_router.py new file mode 100644 index 0000000..f40701c --- /dev/null +++ b/dashboard_backend/maia_dash_api/auth/auth_router.py @@ -0,0 +1,54 @@ +import fastapi +import fastapi.responses + +from .jwt_management import JWTBearer, signJWT +from .auth_schemas import JWT_Response, gen_user_name +from .auth_db import ( + get_user_info, + register_user_db, + log_user_login, + default_username, +) + +auth_router = fastapi.APIRouter(prefix="/auth",tags=['Auth']) + +jwt_contr = JWTBearer() + + +@auth_router.get("/register", response_model=JWT_Response) +async def register_user(background_tasks: fastapi.BackgroundTasks): + user_id = gen_user_name() + background_tasks.add_task(register_user_db, user_id) + return { + "user_id": user_id, + "jwt": signJWT(user_id), + "provided_username": default_username, + "lichess_username": None, + } + + +@auth_router.post("/login_id") +async def login_id( + background_tasks: fastapi.BackgroundTasks, + user_id: str, + screen_width: int, + screen_height: int, +): + user_dict = await get_user_info(user_id) + if user_dict is None: + raise fastapi.HTTPException( + status_code=403, + detail=f"Unreconized user ID: '{user_id}'.", + ) + background_tasks.add_task(log_user_login, "user_id", screen_width, screen_height) + return { + "user_id": user_id, + "provided_username": user_dict["provided_username"], + "lichess_username": user_dict["lichess_username"], + "jwt": signJWT(user_id), + } + + +@auth_router.get("/add_get_user_info") +async def add_get_user_info(token: dict = fastapi.Depends(jwt_contr)): + return await get_user_info(token["user_id"]) diff --git a/dashboard_backend/maia_dash_api/auth/auth_schemas.py b/dashboard_backend/maia_dash_api/auth/auth_schemas.py new file mode 100644 index 0000000..c95da4a --- /dev/null +++ b/dashboard_backend/maia_dash_api/auth/auth_schemas.py @@ -0,0 +1,31 @@ +import random +import typing + +import pydantic + +import jwt #pyjwt + +from .jwt_management import jwt_duration + + +def gen_user_name(): + return f"dash-guest-{random.randint(100000,1000000)}" + + +class JWT_Token(pydantic.BaseModel): + access_token: str = jwt.encode( + { + "user_id": "example", + "expires": 0, + }, + "NOT THE REAL SECRET", + algorithm="HS256", + ) + + +class JWT_Response(pydantic.BaseModel): + user_id: str = gen_user_name() + jwt: JWT_Token + jwt_duration: int = jwt_duration + provided_username: typing.Optional[str] = None + lichess_username: typing.Optional[str] = None diff --git a/dashboard_backend/maia_dash_api/auth/jwt_management.py b/dashboard_backend/maia_dash_api/auth/jwt_management.py new file mode 100644 index 0000000..d0c47b9 --- /dev/null +++ b/dashboard_backend/maia_dash_api/auth/jwt_management.py @@ -0,0 +1,63 @@ +import time +import typing + +import jwt #pyjwt + +import fastapi +import fastapi.security + +SECRET = "JWT_SECRET_TODO_MAKE_BETTER" + +ALGO = "HS256" +jwt_duration = 120 #seconds + + +def signJWT(user_id: str) -> typing.Dict[str, str]: + payload = {"user_id": user_id, "expires": time.time() + jwt_duration} + token = jwt.encode(payload, SECRET, algorithm=ALGO) + return {"access_token": token} + + +def decodeJWT(token: str) -> dict: + if token.startswith("access_token: "): + token = token.replace("access_token: ", "") + try: + decoded_token = jwt.decode(token, SECRET, algorithms=[ALGO]) + except jwt.DecodeError: + return None + if decoded_token["expires"] >= time.time(): + return decoded_token + else: + return None + + +async def register(user_id): + return signJWT(user_id) + + +class JWTBearer(fastapi.security.HTTPBearer): + def __init__(self, auto_error: bool = True): + super().__init__(auto_error=auto_error) + + async def __call__(self, request: fastapi.Request): + credentials: fastapi.security.HTTPAuthorizationCredentials = ( + await super().__call__(request) + ) + if credentials: + if not credentials.scheme == "Bearer": + raise fastapi.HTTPException( + status_code=403, + detail="Invalid authentication scheme.", + ) + jwt_tkn = decodeJWT(credentials.credentials) + if jwt_tkn: + return jwt_tkn + raise fastapi.HTTPException( + status_code=403, + detail="Invalid token or expired token.", + ) + else: + raise fastapi.HTTPException( + status_code=403, + detail="Invalid authorization code.", + ) diff --git a/dashboard_backend/maia_dash_api/auth/oauth.py b/dashboard_backend/maia_dash_api/auth/oauth.py new file mode 100644 index 0000000..66d3846 --- /dev/null +++ b/dashboard_backend/maia_dash_api/auth/oauth.py @@ -0,0 +1,96 @@ +import hashlib +import urllib.parse +import datetime + +import requests + +import fastapi +import fastapi.responses +import starlette.requests + +from authlib.oauth2.rfc7636 import create_s256_code_challenge + +from .auth_router import auth_router +from .jwt_management import decodeJWT, signJWT, jwt_duration +from .auth_db import set_lichess_username, log_player_data + +LICHESS_CLIENT_ID = "RANDOM_ID_TODO_MAKE_BETTER" +LICHESS_CODE_CHALLENGE = "RANDOM_CHALLENGE_TODO_MAKE_BETTER" + + +def make_code_challenge(user_id): + return ( + hashlib.md5(f"{LICHESS_CODE_CHALLENGE}-{user_id}".encode("utf8")).hexdigest() + + hashlib.md5(f"{user_id}-{LICHESS_CODE_CHALLENGE}".encode("utf8")).hexdigest() + ) + + +@auth_router.get("/lichess_login/{jwt_token}") +async def login_via_lichess( + jwt_token: str, + request: starlette.requests.Request, + redirect_path: str = "turing", +): + jwt_token_decode = decodeJWT(jwt_token) + if jwt_token_decode is None: + raise fastapi.HTTPException( + status_code=403, + detail="Invalid token or expired token.", + ) + if redirect_path.startswith("/"): + redirect_path = redirect_path[1:] + query_dict = { + "response_type": "code", + "client_id": LICHESS_CLIENT_ID, + "redirect_uri": request.url_for("auth_via_lichess"), + "state": f"{jwt_token}+{redirect_path}", + "code_challenge_method": "S256", + "code_challenge": create_s256_code_challenge( + make_code_challenge(jwt_token_decode["user_id"]) + ), + } + + print(query_dict) + query_url = f"https://lichess.org/oauth?{urllib.parse.urlencode(query_dict)}" + response = fastapi.responses.RedirectResponse(query_url) + response.set_cookie( + key="jwt_token", + value=jwt_token, + expires=jwt_duration + 1, + ) + return response + + +@auth_router.get(f"/lichess_authorize") +async def auth_via_lichess( + request: starlette.requests.Request, + background_tasks: fastapi.BackgroundTasks, + code: str = None, + state: str = None, +): + jwt_token, redirect_path = state.split("+")[:2] + jwt_token_decode = decodeJWT(jwt_token) + if jwt_token_decode is None: + raise fastapi.HTTPException( + status_code=403, + detail="Invalid token or expired token.", + ) + dat = { + "grant_type": "authorization_code", + "code": code, + "code_verifier": make_code_challenge(jwt_token_decode["user_id"]), + "redirect_uri": request.url_for("auth_via_lichess"), + "client_id": LICHESS_CLIENT_ID, + } + req = requests.post("https://lichess.org/api/token", data=dat) + li_dict = req.json() + header = {"Authorization": f"Bearer {li_dict['access_token']}"} + user_req = requests.get("https://lichess.org/api/account", headers=header) + user_dict = user_req.json() + await set_lichess_username(jwt_token_decode["user_id"], user_dict["id"]) + user_dict["user_id"] = jwt_token_decode["user_id"] + user_dict["server_timestamp"] = datetime.datetime.now() + background_tasks.add_task(log_player_data, user_dict) + return fastapi.responses.RedirectResponse( + f"https://survey.maiachess.com/{redirect_path}" + ) diff --git a/dashboard_backend/maia_dash_api/database/__init__.py b/dashboard_backend/maia_dash_api/database/__init__.py new file mode 100644 index 0000000..537fa29 --- /dev/null +++ b/dashboard_backend/maia_dash_api/database/__init__.py @@ -0,0 +1 @@ +from .db_client import get_dash_db, get_analysis_db, db_disconnect, db_connect, write_one_dash diff --git a/dashboard_backend/maia_dash_api/database/db_client.py b/dashboard_backend/maia_dash_api/database/db_client.py new file mode 100644 index 0000000..d7a1ac6 --- /dev/null +++ b/dashboard_backend/maia_dash_api/database/db_client.py @@ -0,0 +1,27 @@ +import motor.motor_asyncio + + +class DataBase: + client: motor.motor_asyncio.AsyncIOMotorClient = None + + +db = DataBase() + + +async def get_dash_db(): + return db.client["test_dashboard"] + +async def get_analysis_db(): + return db.client["test_analysis"] + +async def db_connect(): + db.client = motor.motor_asyncio.AsyncIOMotorClient() + + +async def db_disconnect(): + db.client.close() + + +async def write_one_dash(table, data): + client = await get_dash_db() + await client[table].insert_one(data) diff --git a/dashboard_backend/main.py b/dashboard_backend/main.py new file mode 100644 index 0000000..9eafff8 --- /dev/null +++ b/dashboard_backend/main.py @@ -0,0 +1,50 @@ +import fastapi +import uvicorn +import starlette.requests +import starlette.middleware.sessions +import starlette.middleware.gzip + +import fastapi.staticfiles +import fastapi.middleware.cors + +import maia_dash_api + +#Test run with +#uvicorn main:app --reload --port 32580 --host 0.0.0.0 +#Docs at http://dashboard.maiachess.com:32580/api/docs + +app = fastapi.FastAPI( + version=maia_dash_api.__version__, + docs_url="/api/docs", + openapi_url="/api/v1/openapi.json", +) + +#Allow NGINX to proxy +app.add_middleware( + uvicorn.middleware.proxy_headers.ProxyHeadersMiddleware, + trusted_hosts="*", +) + +#Disable CORS +app.add_middleware( + fastapi.middleware.cors.CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +#Connect to Mongo on startup +app.add_event_handler("startup", maia_dash_api.db_connect) +app.add_event_handler("shutdown", maia_dash_api.db_disconnect) + +#Have different components be handled separately +app.include_router(maia_dash_api.auth_router) + +@app.get("/api") +async def root(): + return {"message": "Hello World"} + +@app.get("/") +async def root(): + return fastapi.responses.PlainTextResponse("WORKING") From c618fe4b618447310cd8c33dae608eab5975e1ef Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Mon, 11 Oct 2021 20:56:40 +0000 Subject: [PATCH 2/2] added service code --- dashboard_backend/README.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 dashboard_backend/README.md diff --git a/dashboard_backend/README.md b/dashboard_backend/README.md new file mode 100644 index 0000000..6e5f573 --- /dev/null +++ b/dashboard_backend/README.md @@ -0,0 +1,18 @@ +We want to run fastapi with `systemd` so it will be handled automatically. Here's a sample service file + + +Note I'm using hard coded paths from my local user account, you will want to change this + +```[Unit] +Description=Gunicorn manager for Uvicorn serving maia_dash_api +After=network.target + +[Service] +User=reidmcy +Group=www-data +WorkingDirectory=/home/reidmcy/team-project-4-csslab-uoft/dashboard_backend +ExecStart=/home/reidmcy/miniconda/bin/gunicorn --timeout 1000 --env GUNICORN_DEPLOY=PROD --log-file logs/activity.log --access-logfile logs/main_access.log --workers 6 --worker-class uvicorn.workers.UvicornWorker --bind unix:services/maia_dash_api.sock -m 007 main:app + +[Install] +WantedBy=multi-user.target +```