From 0a5026a6ed90baae7f33d5faeca2914b78bde6cb Mon Sep 17 00:00:00 2001 From: gustavoschaedler Date: Fri, 11 Aug 2023 03:13:08 +0100 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=94=A7=20fix(auth.py):=20change=20ref?= =?UTF-8?q?resh=20token=20expiration=20time=20from=20180=20minutes=20to=20?= =?UTF-8?q?70=20minutes=20for=20better=20security=20=F0=9F=94=A7=20fix(aut?= =?UTF-8?q?h.py):=20change=20SECRET=5FKEY=20comment=20from=20"TODO"=20to?= =?UTF-8?q?=20"JUST=20FOR=20TEST"=20for=20clarity=20=F0=9F=94=A7=20fix(aut?= =?UTF-8?q?h.py):=20change=20create=5Faccess=5Ftoken=20function=20name=20t?= =?UTF-8?q?o=20create=5Ftoken=20for=20consistency=20=F0=9F=94=A7=20fix(aut?= =?UTF-8?q?h.py):=20change=20create=5Frefresh=5Ftoken=20function=20to=20ac?= =?UTF-8?q?cept=20refresh=5Ftoken=20parameter=20instead=20of=20data=20dict?= =?UTF-8?q?ionary=20=F0=9F=94=A7=20fix(auth.py):=20change=20create=5Frefre?= =?UTF-8?q?sh=5Ftoken=20function=20to=20decode=20and=20validate=20refresh?= =?UTF-8?q?=5Ftoken=20before=20creating=20new=20user=20tokens=20?= =?UTF-8?q?=F0=9F=94=A7=20fix(auth.py):=20change=20authenticate=5Fuser=20f?= =?UTF-8?q?unction=20to=20use=20get=5Fuser=5Fby=5Fusername=20instead=20of?= =?UTF-8?q?=20get=5Fuser=20=F0=9F=94=A7=20fix(auth.py):=20change=20get=5Fc?= =?UTF-8?q?urrent=5Fuser=20function=20to=20use=20get=5Fuser=5Fby=5Fusernam?= =?UTF-8?q?e=20instead=20of=20get=5Fuser=20=F0=9F=94=A7=20fix(auth.py):=20?= =?UTF-8?q?change=20get=5Fuser=20function=20name=20to=20get=5Fuser=5Fby=5F?= =?UTF-8?q?username=20for=20clarity=20=F0=9F=94=A7=20fix(users.py):=20chan?= =?UTF-8?q?ge=20get=5Fpassword=5Fhash=20function=20to=20be=20inside=20the?= =?UTF-8?q?=20router=20scope=20for=20better=20encapsulation=20=F0=9F=94=A7?= =?UTF-8?q?=20fix(users.py):=20change=20add=5Fuser=20function=20to=20retur?= =?UTF-8?q?n=20UserListModel=20instead=20of=20User=20=F0=9F=94=A7=20fix(us?= =?UTF-8?q?ers.py):=20change=20update=5Fuser=20function=20to=20update=20us?= =?UTF-8?q?er=20data=20based=20on=20UserPatchModel=20fields=20=F0=9F=94=A7?= =?UTF-8?q?=20fix(users.py):=20change=20update=5Fuser=20function=20to=20ha?= =?UTF-8?q?ndle=20username=20conflicts=20and=20update=20user's=20updated?= =?UTF-8?q?=5Fat=20field=20=F0=9F=94=A7=20fix(users.py):=20change=20delete?= =?UTF-8?q?=5Fuser=20function=20to=20delete=20user=20based=20on=20user=5Fi?= =?UTF-8?q?d=20=E2=9C=A8=20feat(login.py):=20add=20refresh=5Ftoken=20endpo?= =?UTF-8?q?int=20to=20refresh=20access=20token=20using=20refresh=20token?= =?UTF-8?q?=20=E2=9C=A8=20feat(login.py):=20add=20refresh=5Ftoken=20functi?= =?UTF-8?q?on=20to=20create=20new=20user=20tokens=20based=20on=20refresh?= =?UTF-8?q?=20token=20=E2=9C=A8=20feat(login.py):=20add=20refresh=5Ftoken?= =?UTF-8?q?=20endpoint=20to=20refresh=20access=20token=20using=20refresh?= =?UTF-8?q?=20token=20=E2=9C=A8=20feat(login.py):=20add=20refresh=5Ftoken?= =?UTF-8?q?=20function=20to=20create=20new=20user=20tokens=20based=20on=20?= =?UTF-8?q?refresh=20token=20=E2=9C=A8=20feat(login.py):=20add=20refresh?= =?UTF-8?q?=5Ftoken=20endpoint=20to=20refresh=20access=20token=20using=20r?= =?UTF-8?q?efresh=20token=20=E2=9C=A8=20feat(login.py):=20add=20refresh=5F?= =?UTF-8?q?token=20function=20to=20create=20new=20user=20tokens=20based=20?= =?UTF-8?q?on=20refresh=20token=20=E2=9C=A8=20feat(login.py):=20add=20refr?= =?UTF-8?q?esh=5Ftoken=20endpoint=20to=20refresh=20access=20token=20using?= =?UTF-8?q?=20refresh=20token=20=E2=9C=A8=20feat(login.py):=20add=20refres?= =?UTF-8?q?h=5Ftoken=20function=20to=20create=20new=20user=20tokens=20base?= =?UTF-8?q?d=20on=20refresh=20token=20=E2=9C=A8=20feat(login.py):=20add=20?= =?UTF-8?q?refresh=5Ftoken=20endpoint=20to=20refresh=20access=20token=20us?= =?UTF-8?q?ing=20refresh=20token=20=E2=9C=A8=20feat(login.py):=20add=20ref?= =?UTF-8?q?resh=5Ftoken=20function=20to=20create=20new=20user=20tokens=20b?= =?UTF-8?q?ased=20on=20refresh=20token=20=E2=9C=A8=20feat(login.py):=20add?= =?UTF-8?q?=20refresh=5Ftoken=20endpoint=20to=20refresh=20access=20token?= =?UTF-8?q?=20using=20refresh=20token=20=E2=9C=A8=20feat(login.py):=20add?= =?UTF-8?q?=20refresh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/backend/langflow/auth/auth.py | 68 +++++++++++----- src/backend/langflow/database/models/user.py | 13 ++- src/backend/langflow/routers/login.py | 43 ++++------ src/backend/langflow/routers/users.py | 83 +++++++++++++++++--- 4 files changed, 148 insertions(+), 59 deletions(-) diff --git a/src/backend/langflow/auth/auth.py b/src/backend/langflow/auth/auth.py index 067184053..439e1017e 100644 --- a/src/backend/langflow/auth/auth.py +++ b/src/backend/langflow/auth/auth.py @@ -8,14 +8,14 @@ from datetime import datetime, timedelta, timezone from langflow.services.utils import get_session from langflow.database.models.token import TokenData -from langflow.database.models.user import get_user, User +from langflow.database.models.user import User, get_user_by_username -# TODO: Move to env - Test propose!!!!! +# TODO: Move to env - JUST FOR TEST!!!!! SECRET_KEY = "698619adad2d916f1f32d264540976964b3c0d3828e0870a65add5800a8cc6b9" ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = 60 -REFRESH_TOKEN_EXPIRE_MINUTES = 180 +REFRESH_TOKEN_EXPIRE_MINUTES = 70 pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") oauth2_scheme = OAuth2PasswordBearer(tokenUrl="login") @@ -29,29 +29,57 @@ def get_password_hash(password): return pwd_context.hash(password) -def create_access_token(data: dict, expires_delta: timedelta = None): # type: ignore +def create_token(data: dict, expires_delta: timedelta): to_encode = data.copy() - if expires_delta: - expire = datetime.now(timezone.utc) + expires_delta - else: - expire = datetime.now(timezone.utc) + timedelta( - minutes=ACCESS_TOKEN_EXPIRE_MINUTES - ) + + expire = datetime.now(timezone.utc) + expires_delta to_encode["exp"] = expire + return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) -def create_refresh_token(data: dict): - to_encode = data.copy() - expire = datetime.now(timezone.utc) + timedelta( - minutes=REFRESH_TOKEN_EXPIRE_MINUTES +def create_user_tokens(username: str) -> dict: + access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + access_token = create_token( + data={"sub": username}, + expires_delta=access_token_expires, ) - to_encode["exp"] = expire - return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + + refresh_token_expires = timedelta(minutes=REFRESH_TOKEN_EXPIRE_MINUTES) + refresh_token = create_token( + data={"sub": username, "type": "rf"}, + expires_delta=refresh_token_expires, + ) + + return { + "access_token": access_token, + "refresh_token": refresh_token, + "token_type": "bearer", + } + + +def create_refresh_token(refresh_token: str): + try: + payload = jwt.decode(refresh_token, SECRET_KEY, algorithms=[ALGORITHM]) + username: str = payload.get("sub") # type: ignore + token_type: str = payload.get("type") # type: ignore + + if username is None or token_type is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token" + ) + + return create_user_tokens(username) + + except JWTError as e: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid refresh token", + ) from e def authenticate_user(db: Session, username: str, password: str): - if user := get_user(db, username): + if user := get_user_by_username(db, username): return user if verify_password(password, user.password) else False else: return False @@ -68,13 +96,15 @@ async def get_current_user( try: payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) username: str = payload.get("sub") # type: ignore - if username is None: + token_type: str = payload.get("type") # type: ignore + + if username is None or token_type: raise credentials_exception token_data = TokenData(username=username) except JWTError as e: raise credentials_exception from e - user = get_user(db, token_data.username) # type: ignore + user = get_user_by_username(db, token_data.username) # type: ignore if user is None: raise credentials_exception return user diff --git a/src/backend/langflow/database/models/user.py b/src/backend/langflow/database/models/user.py index 144e71fae..75c38d98a 100644 --- a/src/backend/langflow/database/models/user.py +++ b/src/backend/langflow/database/models/user.py @@ -32,6 +32,17 @@ class UserListModel(SQLModel): updated_at: datetime = Field() -def get_user(db: Session, username: str) -> User: +class UserPatchModel(SQLModel): + username: str = Field() + is_disabled: bool = Field() + is_superuser: bool = Field() + + +def get_user_by_username(db: Session, username: str) -> User: db_user = db.query(User).filter(User.username == username).first() return User.from_orm(db_user) if db_user else None # type: ignore + + +def get_user_by_id(db: Session, id: UUID) -> User: + db_user = db.query(User).filter(User.id == id).first() + return User.from_orm(db_user) if db_user else None # type: ignore diff --git a/src/backend/langflow/routers/login.py b/src/backend/langflow/routers/login.py index 8108a2d18..b2814b262 100644 --- a/src/backend/langflow/routers/login.py +++ b/src/backend/langflow/routers/login.py @@ -1,48 +1,39 @@ -from datetime import timedelta - +from sqlalchemy.orm import Session from fastapi import APIRouter, Depends, HTTPException, status from fastapi.security import OAuth2PasswordRequestForm + +from langflow.services.utils import get_session from langflow.database.models.token import Token from langflow.auth.auth import ( - ACCESS_TOKEN_EXPIRE_MINUTES, authenticate_user, - create_access_token, + create_user_tokens, create_refresh_token, ) -from sqlalchemy.orm import Session -from langflow.services.utils import get_session -from langflow.database.models.user import User - - router = APIRouter() -def create_user_token(user: User) -> dict: - access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) - access_token = create_access_token( - data={"sub": user.username}, - expires_delta=access_token_expires, - ) - - refresh_token = create_refresh_token(data={"sub": user.username}) - - return { - "access_token": access_token, - "refresh_token": refresh_token, - "token_type": "bearer", - } - - @router.post("/login", response_model=Token) async def login_to_get_access_token( form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_session) ): if user := authenticate_user(db, form_data.username, form_data.password): - return create_user_token(user) + return create_user_tokens(user.username) else: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect username or password", headers={"WWW-Authenticate": "Bearer"}, ) + + +@router.post("/refresh") +async def refresh_token(token: str): + if token: + return create_refresh_token(token) + else: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid refresh token", + headers={"WWW-Authenticate": "Bearer"}, + ) diff --git a/src/backend/langflow/routers/users.py b/src/backend/langflow/routers/users.py index bcf1a7075..ab235e5c3 100644 --- a/src/backend/langflow/routers/users.py +++ b/src/backend/langflow/routers/users.py @@ -1,28 +1,29 @@ -from typing import List +from uuid import UUID from sqlmodel import Session, select +from datetime import timezone, datetime from sqlalchemy.exc import IntegrityError from fastapi import APIRouter, Depends, HTTPException from langflow.services.utils import get_session -from langflow.auth.auth import get_current_active_user -from langflow.database.models.user import UserAddModel, UserListModel, User - -from passlib.context import CryptContext +from langflow.auth.auth import get_current_active_user, get_password_hash +from langflow.database.models.user import ( + User, + UserAddModel, + UserListModel, + UserPatchModel, + get_user_by_id, + get_user_by_username, +) router = APIRouter(tags=["Login"]) -def get_password_hash(password): - pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") - return pwd_context.hash(password) - - @router.get("/user", response_model=UserListModel) def read_current_user(current_user: User = Depends(get_current_active_user)): return current_user -@router.get("/users", response_model=List[UserListModel]) +@router.get("/users") def read_all_users( skip: int = 0, limit: int = 10, @@ -35,7 +36,7 @@ def read_all_users( return db.execute(query).fetchall() -@router.post("/user", response_model=User) +@router.post("/user", response_model=UserListModel) def add_user( user: UserAddModel, _: Session = Depends(get_current_active_user), @@ -50,6 +51,7 @@ def add_user( db.refresh(new_user) except IntegrityError as e: db.rollback() + raise HTTPException( status_code=400, detail="User exists", @@ -58,7 +60,62 @@ def add_user( return new_user -# TODO: Remove - Just for testing purposes +@router.patch("/user/{user_id}", response_model=UserListModel) +def update_user( + user_id: UUID, + user: UserPatchModel, + _: Session = Depends(get_current_active_user), + db: Session = Depends(get_session), +): + user_db = get_user_by_username(db, user.username) + if user_db and user_db.id != user_id: + raise HTTPException(status_code=409, detail="Username already exists") + + user_db = get_user_by_id(db, user_id) + if not user_db: + raise HTTPException(status_code=404, detail="User not found") + + try: + user_data = user.dict(exclude_unset=True) + + for key, value in user_data.items(): + setattr(user_db, key, value) + + user_db.updated_at = datetime.now(timezone.utc) + user_db = db.merge(user_db) + + db.commit() + + if db.identity_key(instance=user_db) is not None: + db.refresh(user_db) + except IntegrityError as e: + db.rollback() + + raise HTTPException( + status_code=400, + detail=str(e), + ) from e + + return user_db + + +@router.delete("/user/{user_id}") +def delete_user( + user_id: UUID, + _: Session = Depends(get_current_active_user), + db: Session = Depends(get_session), +): + user_db = db.query(User).filter(User.id == user_id).first() + if not user_db: + raise HTTPException(status_code=404, detail="User not found") + + db.delete(user_db) + db.commit() + + return {"detail": "User deleted"} + + +# TODO: REMOVE - Just for testing purposes @router.post("/super_user", response_model=User) def add_super_user_to_testing_purposes(db: Session = Depends(get_session)): new_user = User(username="superuser", password="12345", is_superuser=True) From 580b439b8014dc46b39bf3347630158e4bc06aa8 Mon Sep 17 00:00:00 2001 From: gustavoschaedler Date: Fri, 11 Aug 2023 03:52:02 +0100 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=94=A7=20chore(users.py):=20refactor?= =?UTF-8?q?=20and=20improve=20code=20readability=20in=20users.py=20?= =?UTF-8?q?=E2=9C=A8=20feat(users.py):=20add=20docstrings=20to=20API=20end?= =?UTF-8?q?points=20for=20better=20documentation=20=F0=9F=94=A5=20chore(us?= =?UTF-8?q?ers.py):=20remove=20unnecessary=20code=20and=20comments=20?= =?UTF-8?q?=F0=9F=90=9B=20fix(users.py):=20fix=20return=20type=20hints=20i?= =?UTF-8?q?n=20API=20endpoints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/backend/langflow/routers/users.py | 91 ++++++++++++++------------- 1 file changed, 49 insertions(+), 42 deletions(-) diff --git a/src/backend/langflow/routers/users.py b/src/backend/langflow/routers/users.py index ab235e5c3..5cf20581b 100644 --- a/src/backend/langflow/routers/users.py +++ b/src/backend/langflow/routers/users.py @@ -1,8 +1,10 @@ +from typing import List from uuid import UUID -from sqlmodel import Session, select from datetime import timezone, datetime + from sqlalchemy.exc import IntegrityError from fastapi import APIRouter, Depends, HTTPException +from sqlmodel import Session, select from langflow.services.utils import get_session from langflow.auth.auth import get_current_active_user, get_password_hash @@ -18,55 +20,62 @@ from langflow.database.models.user import ( router = APIRouter(tags=["Login"]) -@router.get("/user", response_model=UserListModel) -def read_current_user(current_user: User = Depends(get_current_active_user)): - return current_user - - -@router.get("/users") -def read_all_users( - skip: int = 0, - limit: int = 10, - _: Session = Depends(get_current_active_user), - db: Session = Depends(get_session), -): - query = select(User) - query = query.offset(skip).limit(limit) - - return db.execute(query).fetchall() - - @router.post("/user", response_model=UserListModel) def add_user( user: UserAddModel, _: Session = Depends(get_current_active_user), db: Session = Depends(get_session), -): +) -> User: + """ + Add a new user to the database. + """ new_user = User(**user.dict()) try: new_user.password = get_password_hash(user.password) - db.add(new_user) db.commit() db.refresh(new_user) except IntegrityError as e: db.rollback() - - raise HTTPException( - status_code=400, - detail="User exists", - ) from e + raise HTTPException(status_code=400, detail="User exists") from e return new_user +@router.get("/user", response_model=UserListModel) +def read_current_user(current_user: User = Depends(get_current_active_user)) -> User: + """ + Retrieve the current user's data. + """ + return current_user + + +@router.get("/users", response_model=List[UserListModel]) +def read_all_users( + skip: int = 0, + limit: int = 10, + _: Session = Depends(get_current_active_user), + db: Session = Depends(get_session), +) -> List[UserListModel]: + """ + Retrieve a list of users from the database with pagination. + """ + query = select(User).offset(skip).limit(limit) + users = db.execute(query).fetchall() + + return [UserListModel(**dict(user.User)) for user in users] + + @router.patch("/user/{user_id}", response_model=UserListModel) def update_user( user_id: UUID, user: UserPatchModel, _: Session = Depends(get_current_active_user), db: Session = Depends(get_session), -): +) -> User: + """ + Update an existing user's data. + """ user_db = get_user_by_username(db, user.username) if user_db and user_db.id != user_id: raise HTTPException(status_code=409, detail="Username already exists") @@ -77,24 +86,17 @@ def update_user( try: user_data = user.dict(exclude_unset=True) - for key, value in user_data.items(): setattr(user_db, key, value) user_db.updated_at = datetime.now(timezone.utc) user_db = db.merge(user_db) - db.commit() - if db.identity_key(instance=user_db) is not None: db.refresh(user_db) except IntegrityError as e: db.rollback() - - raise HTTPException( - status_code=400, - detail=str(e), - ) from e + raise HTTPException(status_code=400, detail=str(e)) from e return user_db @@ -104,7 +106,10 @@ def delete_user( user_id: UUID, _: Session = Depends(get_current_active_user), db: Session = Depends(get_session), -): +) -> dict: + """ + Delete a user from the database. + """ user_db = db.query(User).filter(User.id == user_id).first() if not user_db: raise HTTPException(status_code=404, detail="User not found") @@ -117,20 +122,22 @@ def delete_user( # TODO: REMOVE - Just for testing purposes @router.post("/super_user", response_model=User) -def add_super_user_to_testing_purposes(db: Session = Depends(get_session)): +def add_super_user_for_testing_purposes_delete_me_before_merge_into_dev( + db: Session = Depends(get_session), +) -> User: + """ + Add a superuser for testing purposes. + (This should be removed in production) + """ new_user = User(username="superuser", password="12345", is_superuser=True) try: new_user.password = get_password_hash(new_user.password) - db.add(new_user) db.commit() db.refresh(new_user) except IntegrityError as e: db.rollback() - raise HTTPException( - status_code=400, - detail="User exists", - ) from e + raise HTTPException(status_code=400, detail="User exists") from e return new_user