From 3b9a491d8974c5a1cd1539e056d79e020ceb70d4 Mon Sep 17 00:00:00 2001 From: Cullen Watson Date: Sun, 9 Jul 2023 15:15:39 -0500 Subject: [PATCH] feat(auth): add auth to jobs endpoint --- .gitignore | 1 + api/__init__.py | 5 +++- api/core/users/__init__.py | 20 +++++++++++++++ api/v1/__init__.py | 2 ++ api/v1/jobs/__init__.py | 5 ++-- api/v1/token/__init__.py | 23 ++++++++++++++++++ api/v1/token/auth.py | 50 ++++++++++++++++++++++++++++++++++++++ api/v1/token/db_utils.py | 37 ++++++++++++++++++++++++++++ main.py | 14 +++++++---- requirements.txt | 8 ++++-- settings.py | 9 +++++++ 11 files changed, 164 insertions(+), 10 deletions(-) create mode 100644 api/core/users/__init__.py create mode 100644 api/v1/token/__init__.py create mode 100644 api/v1/token/auth.py create mode 100644 api/v1/token/db_utils.py create mode 100644 settings.py diff --git a/.gitignore b/.gitignore index d895cdb..98e943c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ /ven/ **/__pycache__/ *.pyc +.env \ No newline at end of file diff --git a/api/__init__.py b/api/__init__.py index 3479834..02838e3 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -1,5 +1,8 @@ from fastapi import APIRouter from .v1 import router as v1_router -router = APIRouter(prefix="/api", tags=["api"]) +router = APIRouter( + prefix="/api", + tags=["api"], +) router.include_router(v1_router) diff --git a/api/core/users/__init__.py b/api/core/users/__init__.py new file mode 100644 index 0000000..5b33b94 --- /dev/null +++ b/api/core/users/__init__.py @@ -0,0 +1,20 @@ +from pydantic import BaseModel + +class User(BaseModel): + username: str + full_name: str + email: str + disabled: bool + + +class UserInDB(User): + hashed_password: str + + +class TokenData(BaseModel): + username: str + + +class Token(BaseModel): + access_token: str + token_type: str diff --git a/api/v1/__init__.py b/api/v1/__init__.py index ea42d12..33f258d 100644 --- a/api/v1/__init__.py +++ b/api/v1/__init__.py @@ -1,5 +1,7 @@ from fastapi import APIRouter from .jobs import router as jobs_router +from .token import router as token_router router = APIRouter(prefix="/v1") router.include_router(jobs_router) +router.include_router(token_router) diff --git a/api/v1/jobs/__init__.py b/api/v1/jobs/__init__.py index 2a87cc6..5ba1ec9 100644 --- a/api/v1/jobs/__init__.py +++ b/api/v1/jobs/__init__.py @@ -1,11 +1,12 @@ -from fastapi import APIRouter +from fastapi import APIRouter, Depends from api.core.scrapers.indeed import IndeedScraper from api.core.scrapers.ziprecruiter import ZipRecruiterScraper from api.core.scrapers.linkedin import LinkedInScraper from api.core.scrapers import ScraperInput, Site +from ...v1.token.auth import get_active_current_user -router = APIRouter(prefix="/jobs") +router = APIRouter(prefix="/jobs", dependencies=[Depends(get_active_current_user)]) SCRAPER_MAPPING = { Site.LINKEDIN: LinkedInScraper, diff --git a/api/v1/token/__init__.py b/api/v1/token/__init__.py new file mode 100644 index 0000000..e3eebf3 --- /dev/null +++ b/api/v1/token/__init__.py @@ -0,0 +1,23 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordRequestForm + +from api.core.users import Token +from .db_utils import authenticate_user +from .auth import create_access_token + +ACCESS_TOKEN_EXPIRE_MINUTES = 30 +router = APIRouter(prefix="/token") + + +@router.post("/", response_model=Token) +async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends()): + user = authenticate_user(form_data.username, form_data.password) + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + + access_token = create_access_token(data={"sub": user.username}) + return {"access_token": access_token, "token_type": "bearer"} diff --git a/api/v1/token/auth.py b/api/v1/token/auth.py new file mode 100644 index 0000000..8cf6fd6 --- /dev/null +++ b/api/v1/token/auth.py @@ -0,0 +1,50 @@ +from datetime import datetime, timedelta + +from jose import jwt, JWTError +from fastapi import HTTPException, status, Depends +from fastapi.security import OAuth2PasswordBearer + +from settings import * +from api.core.users import TokenData +from .db_utils import UserInDB, get_user + +load_dotenv() +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/token") + + +def create_access_token(data: dict): + print(JWT_SECRET_KEY) + to_encode = data.copy() + expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, JWT_SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + + +async def get_current_user(token: str = Depends(oauth2_scheme)): + credential_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = jwt.decode(token, JWT_SECRET_KEY, algorithms=[ALGORITHM]) + username: str = payload.get("sub") + if username is None: + raise credential_exception + token_data = TokenData(username=username) + except JWTError: + raise credential_exception + + current_user = get_user(token_data.username) + if current_user is None: + raise credential_exception + return current_user + + +async def get_active_current_user(current_user: UserInDB = Depends(get_current_user)): + if current_user.disabled: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive user." + ) + return current_user diff --git a/api/v1/token/db_utils.py b/api/v1/token/db_utils.py new file mode 100644 index 0000000..04fe8af --- /dev/null +++ b/api/v1/token/db_utils.py @@ -0,0 +1,37 @@ +from passlib.context import CryptContext + +from supabase_py import create_client, Client +from api.core.users import UserInDB +from settings import SUPABASE_URL, SUPABASE_KEY + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY) + +def get_user(username: str): + result = supabase.table('users').select().eq('username', username).execute() + + if 'error' in result and result['error']: + print(f"Error: {result['error']['message']}") + return None + else: + if result['data']: + user_data = result['data'][0] # get the first (and should be only) user with the matching username + return UserInDB(**user_data) + else: + return None + +def verify_password(password: str, hashed_password: str): + return pwd_context.verify(password, hashed_password) + + +def get_password_hash(password): + return pwd_context.hash(password) + + +def authenticate_user(username: str, password: str): + user = get_user(username) + if not user: + return False + if not verify_password(password, user.hashed_password): + return False + return user diff --git a/main.py b/main.py index 2028fb9..f87c3d1 100644 --- a/main.py +++ b/main.py @@ -1,11 +1,15 @@ from fastapi import FastAPI +from supabase_py import create_client, Client from api import router as api_router -app = FastAPI() +app = FastAPI( + title="JobSpy Backend", + description="Endpoints for job board scrapers", + version="1.0.0", +) app.include_router(api_router) - -@app.get("/") -async def root(): - return {"message": "JobSpy Backend"} +@app.get("/", tags=["health"]) +async def health_check(): + return {"message": "JobSpy ready to scrape"} diff --git a/requirements.txt b/requirements.txt index dddfd55..b48c7e6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,11 @@ fastapi~=0.99.1 pydantic~=1.10.11 beautifulsoup4~=4.12.2 -requests~=2.31.0 +requests pip~=21.3.1 wheel~=0.37.1 -setuptools~=60.2.0 \ No newline at end of file +setuptools~=60.2.0 +passlib~=1.7.4 +cryptography~=41.0.1 +python-jose~=3.3.0 +python-dotenv~=1.0.0 \ No newline at end of file diff --git a/settings.py b/settings.py new file mode 100644 index 0000000..39b21eb --- /dev/null +++ b/settings.py @@ -0,0 +1,9 @@ +from dotenv import load_dotenv +import os + +load_dotenv() +SUPABASE_URL = os.environ.get("SUPABASE_URL") +SUPABASE_KEY = os.environ.get("SUPABASE_KEY") +JWT_SECRET_KEY = os.environ.get("JWT_SECRET_KEY") +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 30