From b886682ec6993fb902c9c243f1b7ad618e57f4b1 Mon Sep 17 00:00:00 2001 From: omoya Date: Thu, 13 Jun 2024 09:32:03 +0200 Subject: [PATCH 1/4] Draft: Authentication Service Model and Views --- requirements.txt | 1 + src/data/database.py | 2 ++ src/models/authentication.py | 14 ++++++++++++++ src/services/authentication.py | 0 src/views/__init__.py | 0 src/views/authentication.py | 25 +++++++++++++++++++++++++ 6 files changed, 42 insertions(+) create mode 100644 src/models/authentication.py create mode 100644 src/services/authentication.py create mode 100644 src/views/__init__.py create mode 100644 src/views/authentication.py diff --git a/requirements.txt b/requirements.txt index bd19035..e6faa52 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,3 +7,4 @@ jinja2==3.1.3 pyyaml==6.0.1 pytest types-requests +passlib diff --git a/src/data/database.py b/src/data/database.py index 914780c..03c7a54 100644 --- a/src/data/database.py +++ b/src/data/database.py @@ -1,8 +1,10 @@ from typing import Any, Generator from sqlmodel import SQLModel, create_engine, Session from sqlalchemy_utils import database_exists, create_database +from sqlalchemy.ext.declarative import declarative_base import constants +Base = declarative_base() def create_tables(db_engine) -> None: from models.turnilo_dashboard import TurniloDashboard diff --git a/src/models/authentication.py b/src/models/authentication.py new file mode 100644 index 0000000..212572c --- /dev/null +++ b/src/models/authentication.py @@ -0,0 +1,14 @@ +from sqlalchemy import Column, Integer, String, Boolean + +from data.database import Base + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + tenant_id = Column(Integer, index=True) # Not sure about this one + email = Column(String, unique=True, index=True) + hashed_password = Column(String) + is_active = Column(Boolean, default=True) + oauth_provider = Column(String, default=None) \ No newline at end of file diff --git a/src/services/authentication.py b/src/services/authentication.py new file mode 100644 index 0000000..e69de29 diff --git a/src/views/__init__.py b/src/views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/views/authentication.py b/src/views/authentication.py new file mode 100644 index 0000000..07bbb8a --- /dev/null +++ b/src/views/authentication.py @@ -0,0 +1,25 @@ +from pydantic import BaseModel +from passlib.context import CryptContext + +from models.authentication import User as UserModel + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def get_password_hash(password) -> str: + return pwd_context.hash(password) + + +class UserRequest(BaseModel): + email: str + password: str + + def to_model(self, tenant_id: int, oauth_provider: str) -> UserModel: + return UserModel( + tenant_id=tenant_id, + email=self.email, + hashed_password=get_password_hash(self.password), # TODO: Interline gibberish for extra protection + oauth_provider=oauth_provider, + ) + + From e13dd11da4f0d6620f782dd85cd1db4503ec3963 Mon Sep 17 00:00:00 2001 From: omoya Date: Fri, 14 Jun 2024 09:21:14 +0200 Subject: [PATCH 2/4] TBS: giving shape to the model, view and controller --- src/constants.py | 5 +++ src/data/database.py | 1 + src/exceptions/__init__.py | 0 src/exceptions/http.py | 24 +++++++++++ src/models/authentication.py | 7 +++- src/routes/authentication.py | 58 +++++++++++++++++++++++++++ src/services/authentication.py | 73 ++++++++++++++++++++++++++++++++++ src/views/authentication.py | 21 +++++----- 8 files changed, 175 insertions(+), 14 deletions(-) create mode 100644 src/exceptions/__init__.py create mode 100644 src/exceptions/http.py create mode 100644 src/routes/authentication.py diff --git a/src/constants.py b/src/constants.py index 38f3a22..b17fd12 100644 --- a/src/constants.py +++ b/src/constants.py @@ -15,3 +15,8 @@ # API URL_PATH = "/rest" + +# AUTHENTICATION :TODO - Move to a separate file +SECRET_KEY = "mysecretkey" +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 30 diff --git a/src/data/database.py b/src/data/database.py index 03c7a54..4d01139 100644 --- a/src/data/database.py +++ b/src/data/database.py @@ -6,6 +6,7 @@ Base = declarative_base() + def create_tables(db_engine) -> None: from models.turnilo_dashboard import TurniloDashboard SQLModel.metadata.create_all(db_engine, tables=[TurniloDashboard.__table__]) diff --git a/src/exceptions/__init__.py b/src/exceptions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/exceptions/http.py b/src/exceptions/http.py new file mode 100644 index 0000000..50e5efd --- /dev/null +++ b/src/exceptions/http.py @@ -0,0 +1,24 @@ +from fastapi import HTTPException, status + + +def new_credentials_exception() -> HTTPException: + return HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + +def new_conflict_exception(msg: str = "Conflict with existing data") -> HTTPException: + return HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=msg, + ) + + +def new_user_not_found_exception() -> HTTPException: + return HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect email or password", + headers={"WWW-Authenticate": "Bearer"}, + ) diff --git a/src/models/authentication.py b/src/models/authentication.py index 212572c..a3a4b94 100644 --- a/src/models/authentication.py +++ b/src/models/authentication.py @@ -7,8 +7,11 @@ class User(Base): __tablename__ = "users" id = Column(Integer, primary_key=True, index=True) - tenant_id = Column(Integer, index=True) # Not sure about this one + tenant_id = Column(Integer, index=True) # Not sure about this one email = Column(String, unique=True, index=True) hashed_password = Column(String) is_active = Column(Boolean, default=True) - oauth_provider = Column(String, default=None) \ No newline at end of file + oauth_provider = Column(String, default=None) # Provider used to Register/Login this one + + def get_hashed_password(self) -> str: + return self.hashed_password.to_string() diff --git a/src/routes/authentication.py b/src/routes/authentication.py new file mode 100644 index 0000000..101a757 --- /dev/null +++ b/src/routes/authentication.py @@ -0,0 +1,58 @@ +import http +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm +from sqlalchemy.orm import Session +from pydantic import BaseModel +from passlib.context import CryptContext +from jose import JWTError, jwt +from datetime import datetime, timedelta +from authlib.integrations.starlette_client import OAuth +from starlette.config import Config as StarletteConfig +from starlette.middleware.sessions import SessionMiddleware +from starlette.requests import Request +from starlette.responses import JSONResponse + +from data import database as db +from exceptions import http as http_exceptions +from src.views import authentication as auth_view +from src.models import authentication as auth_model +from src.services import authentication as auth_service + +auth_router = APIRouter() + + +SECRET_KEY = "mysecretkey" +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 30 + + +config = StarletteConfig() +oauth = OAuth(config) + + +@auth_router.post("/register") +async def register_user(user: auth_view.UserRequest, db: Session = Depends(db.get_session)): + db_user = auth_service.get_user_by_email(db, user.email) + if db_user: + raise http_exceptions.new_conflict_exception("User already exists") + await auth_service.create_user(db, user.to_model(tenant_id=1, oauth_provider="local")) + # TODO: Find response model + return {"message": "User registered successfully"} + + +@auth_router.post("/token", response_model=auth_view.Token) +async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends(), + db: Session = Depends(db.get_session)): + user = await auth_service.get_user_by_email(db, form_data.username) + if not user or not auth_service.verify_password(form_data.password, user.get_hashed_password()): + raise http_exceptions.new_user_not_found_exception() + access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + access_token = auth_service.create_access_token( + data={"sub": user.email}, expires_delta=access_token_expires + ) + return {"access_token": access_token, "token_type": "bearer"} + + +@auth_router.get("/protected") +async def read_protected_route(current_user: auth_model.User = Depends(auth_service.get_current_user)): + return {"message": "You are authenticated!", "user": current_user.email} diff --git a/src/services/authentication.py b/src/services/authentication.py index e69de29..717bb6b 100644 --- a/src/services/authentication.py +++ b/src/services/authentication.py @@ -0,0 +1,73 @@ +""" +Authentication service definition for the DataHangar backend. +""" + +from datetime import datetime, timedelta +from jose import JWTError, jwt +from sqlalchemy.orm import Session +from passlib.context import CryptContext + +from src.exceptions import http as http_exceptions +from src import constants +from src.views import authentication as auth_view +from src.models import authentication as auth_model +import constants + + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def get_password_hash(password) -> str: + return pwd_context.hash(password) + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + return pwd_context.verify(plain_password, hashed_password) + + +def new_user(tenant_id: int, email: str, password: str, oauth_provider: str) -> auth_model.User: + return auth_model.User( + tenant_id=tenant_id, + email=email, + hashed_password=get_password_hash(password), + oauth_provider=oauth_provider + ) + + +def create_access_token(data: dict, expires_delta: timedelta) -> str: + to_encode = data.copy() + if expires_delta: + expire = datetime.now() + expires_delta + else: + expire = datetime.now() + timedelta(minutes=constants.ACCESS_TOKEN_EXPIRE_MINUTES) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, constants.SECRET_KEY, algorithm=constants.ALGORITHM) + return encoded_jwt + + +def parse_token(token: str) -> auth_view.Token: + try: + payload = jwt.decode(token, constants.SECRET_KEY, algorithms=[constants.ALGORITHM]) + return auth_view.Token(**payload) + except JWTError: + raise http_exceptions.new_credentials_exception() + + +async def get_current_user(token: str, db: Session): + t = parse_token(token) + user = get_user_by_email(db, email=t.email) + if user is None: + raise http_exceptions.new_credentials_exception() + return user + + +async def get_user_by_email(db: Session, email: str) -> auth_model.User: + return db.query(auth_model.User).filter(auth_model.User.email == email).one() + + +async def create_user(db: Session, user: auth_model.User) -> auth_model.User: + # TODO: Maybe we could create the transaction for the session before calling this function? + db.add(user) + db.commit() + db.refresh(user) + return user diff --git a/src/views/authentication.py b/src/views/authentication.py index 07bbb8a..93000d1 100644 --- a/src/views/authentication.py +++ b/src/views/authentication.py @@ -1,25 +1,22 @@ from pydantic import BaseModel -from passlib.context import CryptContext -from models.authentication import User as UserModel -pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") - - -def get_password_hash(password) -> str: - return pwd_context.hash(password) +from models import authentication as auth_model +from services import authentication as auth_service class UserRequest(BaseModel): email: str password: str - def to_model(self, tenant_id: int, oauth_provider: str) -> UserModel: - return UserModel( - tenant_id=tenant_id, - email=self.email, - hashed_password=get_password_hash(self.password), # TODO: Interline gibberish for extra protection + def to_model(self, tenant_id: int, oauth_provider: str) -> auth_model.User: + return auth_service.new_user( + tenant_id=tenant_id, + email=self.email, + password=self.password, oauth_provider=oauth_provider, ) +class Token(BaseModel): + email: str From 39fc5689e61d19e7edaed0ee919a66a3fea95e5e Mon Sep 17 00:00:00 2001 From: omoya Date: Fri, 14 Jun 2024 09:22:45 +0200 Subject: [PATCH 3/4] Update requirements.txt --- requirements.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/requirements.txt b/requirements.txt index e6faa52..8a32bd4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,3 +8,5 @@ pyyaml==6.0.1 pytest types-requests passlib +python-jose +authlib From 260f501983426739a21510f55699baf2372313e5 Mon Sep 17 00:00:00 2001 From: omoya Date: Fri, 14 Jun 2024 09:23:44 +0200 Subject: [PATCH 4/4] TBS --- src/constants.py | 1 + src/routes/authentication.py | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/src/constants.py b/src/constants.py index b17fd12..15a2fc5 100644 --- a/src/constants.py +++ b/src/constants.py @@ -17,6 +17,7 @@ URL_PATH = "/rest" # AUTHENTICATION :TODO - Move to a separate file + SECRET_KEY = "mysecretkey" ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = 30 diff --git a/src/routes/authentication.py b/src/routes/authentication.py index 101a757..00f3404 100644 --- a/src/routes/authentication.py +++ b/src/routes/authentication.py @@ -4,7 +4,6 @@ from sqlalchemy.orm import Session from pydantic import BaseModel from passlib.context import CryptContext -from jose import JWTError, jwt from datetime import datetime, timedelta from authlib.integrations.starlette_client import OAuth from starlette.config import Config as StarletteConfig