From 717596f55ca0eb30370a6b00131f6b60359ee72a Mon Sep 17 00:00:00 2001 From: Olly Hearn Date: Wed, 27 Mar 2024 20:00:59 +0300 Subject: [PATCH] aboba --- .gitignore | 3 +- .vscode/launch.json | 23 +++++ backend/app/config/jwt_config.py | 4 + backend/app/db/models.py | 6 +- backend/app/db/schemas.py | 18 ---- backend/app/main.py | 2 +- backend/app/views/auth/api.py | 134 ++++++++++++----------------- backend/app/views/auth/crud.py | 28 ------ backend/app/views/auth/schemas.py | 27 ++++++ backend/app/views/auth/services.py | 99 +++++++++++++++++++++ backend/requirements.txt | 5 +- dev.yml | 10 ++- 12 files changed, 226 insertions(+), 133 deletions(-) create mode 100644 .vscode/launch.json create mode 100644 backend/app/config/jwt_config.py delete mode 100644 backend/app/db/schemas.py delete mode 100644 backend/app/views/auth/crud.py create mode 100644 backend/app/views/auth/schemas.py create mode 100644 backend/app/views/auth/services.py diff --git a/.gitignore b/.gitignore index 02b75f3..96b9cd7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ **/postgres_data **/__pycache__ -**.pyc \ No newline at end of file +**.pyc +**/secret.py \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..8d55733 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,23 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Python Debugger: Remote Attach", + "type": "debugpy", + "request": "attach", + "connect": { + "host": "localhost", + "port": 5678 + }, + "pathMappings": [ + { + "localRoot": "${workspaceFolder}/backend/app", + "remoteRoot": "." + } + ] + } + ] +} \ No newline at end of file diff --git a/backend/app/config/jwt_config.py b/backend/app/config/jwt_config.py new file mode 100644 index 0000000..0e78c0f --- /dev/null +++ b/backend/app/config/jwt_config.py @@ -0,0 +1,4 @@ +from .secret import SECRET_KEY + +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 30 diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 31cf4b5..6b5b9ac 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -1,5 +1,7 @@ from sqlalchemy import Boolean, Column, ForeignKey, Integer, String +from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import relationship +import uuid from .database import Base @@ -7,8 +9,8 @@ from .database import Base class User(Base): __tablename__ = "users" - id = Column(Integer, primary_key=True) - email = Column(String, unique=True, index=True) + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + name = Column(String, index=True) username = Column(String(length=24), unique=True, index=True) hashed_password = Column(String) is_active = Column(Boolean, default=True) diff --git a/backend/app/db/schemas.py b/backend/app/db/schemas.py deleted file mode 100644 index 3b049ab..0000000 --- a/backend/app/db/schemas.py +++ /dev/null @@ -1,18 +0,0 @@ -from typing import Union -from pydantic import BaseModel - - -class UserBase(BaseModel): - email: str - - -class UserCreate(UserBase): - password: str - - -class User(UserBase): - id: int - is_active: bool - - class Config: - orm_mode = True diff --git a/backend/app/main.py b/backend/app/main.py index 230c668..abd815d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -14,5 +14,5 @@ app.include_router(auth_router) @app.get("/") -def read_root(): +async def read_root(): return {"message": "OK"} diff --git a/backend/app/views/auth/api.py b/backend/app/views/auth/api.py index cfa7bad..fd7a08e 100644 --- a/backend/app/views/auth/api.py +++ b/backend/app/views/auth/api.py @@ -1,11 +1,18 @@ -from fastapi import APIRouter, Depends, FastAPI, HTTPException, status +from datetime import datetime, timedelta, timezone from typing import Annotated, Union -from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from sqlalchemy.orm import Session -from . import crud +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordRequestForm + + +from pydantic import BaseModel + +from ...config import jwt_config from ...dependencies import get_db -from ...db import schemas +from . import schemas +from . import services + router = APIRouter( prefix="/auth", @@ -13,92 +20,57 @@ router = APIRouter( dependencies=[Depends(get_db)], responses={404: {"description": "Not found"}}, ) -oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") -@router.post("/users/", response_model=schemas.User) -def create_user(user: schemas.UserCreate, db: Session = Depends(get_db)): - db_user = crud.get_user_by_email(db, email=user.email) - if db_user: - raise HTTPException(status_code=400, detail="Email already registered") - return crud.create_user(db=db, user=user) - - -@router.get("/", response_model=list[schemas.User]) -def read_users(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)): - users = crud.get_users(db, skip=skip, limit=limit) - return users - - -@router.get("/users/{user_id}", response_model=schemas.User) -def read_user(user_id: int, db: Session = Depends(get_db)): - db_user = crud.get_user(db, user_id=user_id) - if db_user is None: - raise HTTPException - - -def fake_hash_password(password: str): - return "fakehashed" + password - - -class User(BaseModel): - username: str - email: Union[str, None] = None - full_name: Union[str, None] = None - disabled: Union[bool, None] = None - - -class UserInDB(User): - hashed_password: str - - -def get_user(db, username: str): - if username in db: - user_dict = db[username] - return UserInDB(**user_dict) - - -def fake_decode_token(token): - # This doesn't provide any security at all - # Check the next version - user = get_user(fake_users_db, token) - return user - - -async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]): - user = fake_decode_token(token) +@router.post("/token") +async def login_for_access_token( + form_data: Annotated[OAuth2PasswordRequestForm, Depends()], + db: Annotated[Session, Depends(get_db)], +) -> schemas.Token: + user = services.authenticate_user(db, form_data.username, form_data.password) if not user: raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid authentication credentials", + status_code=status.HTTP_409_CONFLICT, + detail="Incorrect username or password", headers={"WWW-Authenticate": "Bearer"}, ) + access_token_expires = timedelta(minutes=jwt_config.ACCESS_TOKEN_EXPIRE_MINUTES) + access_token = services.create_access_token( + data={"sub": user.username}, expires_delta=access_token_expires + ) + return schemas.Token(access_token=access_token, token_type="bearer") + + +@router.post("/register") +async def register( + user_data: Annotated[schemas.UserRegister, Depends()], + db: Annotated[Session, Depends(get_db)], +) -> schemas.User: + user = services.get_user_by_username(db, user_data.username) + if user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User with this username already exists", + headers={"WWW-Authenticate": "Bearer"}, + ) + user = services.create_user( + db=db, + username=user_data.username, + plain_password=user_data.password, + name=user_data.name, + ) return user -async def get_current_active_user( - current_user: Annotated[User, Depends(get_current_user)], -): - if current_user.disabled: - raise HTTPException(status_code=400, detail="Inactive user") - return current_user - - -@app.post("/token") -async def login(form_data: Annotated[OAuth2PasswordRequestForm, Depends()]): - user_dict = fake_users_db.get(form_data.username) - if not user_dict: - raise HTTPException(status_code=400, detail="Incorrect username or password") - user = UserInDB(**user_dict) - hashed_password = fake_hash_password(form_data.password) - if not hashed_password == user.hashed_password: - raise HTTPException(status_code=400, detail="Incorrect username or password") - - return {"access_token": user.username, "token_type": "bearer"} - - -@app.get("/users/me") +@router.get("/users/me/", response_model=schemas.User) async def read_users_me( - current_user: Annotated[User, Depends(get_current_active_user)], + current_user: Annotated[schemas.User, Depends(services.get_current_active_user)], ): return current_user + + +# @app.get("/users/me/items/") +# async def read_own_items( +# current_user: Annotated[User, Depends(get_current_active_user)], +# ): +# return [{"item_id": "Foo", "owner": current_user.username}] diff --git a/backend/app/views/auth/crud.py b/backend/app/views/auth/crud.py deleted file mode 100644 index 681a289..0000000 --- a/backend/app/views/auth/crud.py +++ /dev/null @@ -1,28 +0,0 @@ -from sqlalchemy.orm import Session - -from ...db import models, schemas - - -def get_user_by_id(db: Session, user_id: int): - return db.query(models.User).filter(models.User.id == user_id).first() - - -def get_user_by_username(db: Session, username: int): - return db.query(models.User).filter(models.User.username == username).first() - - -def get_user_by_email(db: Session, email: str): - return db.query(models.User).filter(models.User.email == email).first() - - -def get_users(db: Session, skip: int = 0, limit: int = 100): - return db.query(models.User).offset(skip).limit(limit).all() - - -def create_user(db: Session, user: schemas.UserCreate): - fake_hashed_password = user.password + "notreallyhashed" - db_user = models.User(email=user.email, hashed_password=fake_hashed_password) - db.add(db_user) - db.commit() - db.refresh(db_user) - return db_user diff --git a/backend/app/views/auth/schemas.py b/backend/app/views/auth/schemas.py new file mode 100644 index 0000000..f81374a --- /dev/null +++ b/backend/app/views/auth/schemas.py @@ -0,0 +1,27 @@ +from typing import Union +from pydantic import BaseModel + + +class User(BaseModel): + username: str + name: Union[str, None] = None + + +class UserInDB(User): + hashed_password: str + + class Config: + from_attributes = True + + +class UserRegister(User): + plain_password: str + + +class Token(BaseModel): + access_token: str + token_type: str + + +class TokenData(BaseModel): + username: Union[str, None] = None diff --git a/backend/app/views/auth/services.py b/backend/app/views/auth/services.py new file mode 100644 index 0000000..bcb108d --- /dev/null +++ b/backend/app/views/auth/services.py @@ -0,0 +1,99 @@ +from fastapi import status, HTTPException, Depends +from fastapi.security import OAuth2PasswordBearer +from sqlalchemy.orm import Session +from jose import JWTError, jwt +from typing import Annotated, Union +from datetime import timedelta +from passlib.context import CryptContext + +from ...db import models +from . import schemas +from ...dependencies import get_db +from ...config import jwt_config + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") + + +def verify_password(plain_password, hashed_password): + return pwd_context.verify(plain_password, hashed_password) + + +def get_password_hash(password): + return pwd_context.hash(password) + + +def get_user_by_id(db: Session, user_id: int): + return db.query(models.User).filter(models.User.id == user_id).first() + + +def get_user_by_username(db: Session, username: int): + return db.query(models.User).filter(models.User.username == username).first() + + +def authenticate_user(db: Session, username: str, password: str): + user = get_user_by_username(db, username) + if not user: + return False + if not verify_password(password, user.hashed_password): + return False + return user + + +def create_access_token(data: dict, expires_delta: Union[timedelta, None] = None): + to_encode = data.copy() + if expires_delta: + expire = datetime.now(timezone.utc) + expires_delta + else: + expire = datetime.now(timezone.utc) + timedelta(minutes=15) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode( + to_encode, jwt_config.SECRET_KEY, algorithm=jwt_config.ALGORITHM + ) + return encoded_jwt + + +def create_user( + db: Session, username: str, plain_password: str, name: Union[str, None] = None +) -> schemas.User: + user = models.User( + username=username, + name=name, + hashed_password=get_password_hash(plain_password), + ) + db.add(user) + db.commit() + return schemas.User(user) + + +async def get_current_user( + token: Annotated[str, Depends(oauth2_scheme)], + db: Annotated[Session, Depends(get_db)], +) -> schemas.User: + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = jwt.decode( + token, jwt_config.SECRET_KEY, algorithms=[jwt_config.ALGORITHM] + ) + username: str = payload.get("sub") + if username is None: + raise credentials_exception + token_data = TokenData(username=username) + except JWTError: + raise credentials_exception + user = get_user(fake_users_db, username=token_data.username) + if user is None: + raise credentials_exception + return user + + +async def get_current_active_user( + current_user: Annotated[schemas.User, Depends(get_current_user)], +): + if current_user.disabled: + raise HTTPException(status_code=400, detail="Inactive user") + return current_user diff --git a/backend/requirements.txt b/backend/requirements.txt index 516838f..0ac6f0c 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,4 +1,7 @@ fastapi[all] +uvicorn pydantic sqlalchemy -psycopg2-binary \ No newline at end of file +psycopg2-binary +python-jose[cryptography] +passlib[bcrypt] \ No newline at end of file diff --git a/dev.yml b/dev.yml index 9237f6d..1151523 100644 --- a/dev.yml +++ b/dev.yml @@ -3,12 +3,15 @@ services: build: context: backend ports: - - "5000" + - "8000" env_file: - path: ./env/backend/dev.env required: true volumes: - ./backend/app:/code/app:z + depends_on: + postgres: + condition: service_healthy frontend: build: context: frontend @@ -36,3 +39,8 @@ services: env_file: - path: ./env/postgres.env required: true + healthcheck: + test: ["CMD-SHELL", "pg_isready -d db --user user"] + interval: 2s + timeout: 2s + retries: 5