diff --git a/Backend/app/__pycache__/__init__.cpython-313.pyc b/Backend/app/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..01b4a73 Binary files /dev/null and b/Backend/app/__pycache__/__init__.cpython-313.pyc differ diff --git a/Backend/app/__pycache__/main.cpython-313.pyc b/Backend/app/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000..f145e97 Binary files /dev/null and b/Backend/app/__pycache__/main.cpython-313.pyc differ diff --git a/Backend/app/api/__pycache__/__init__.cpython-313.pyc b/Backend/app/api/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..1774222 Binary files /dev/null and b/Backend/app/api/__pycache__/__init__.cpython-313.pyc differ diff --git a/Backend/app/api/__pycache__/auth.cpython-313.pyc b/Backend/app/api/__pycache__/auth.cpython-313.pyc new file mode 100644 index 0000000..a5d7f54 Binary files /dev/null and b/Backend/app/api/__pycache__/auth.cpython-313.pyc differ diff --git a/Backend/app/api/__pycache__/deps.cpython-313.pyc b/Backend/app/api/__pycache__/deps.cpython-313.pyc new file mode 100644 index 0000000..3b9c8ae Binary files /dev/null and b/Backend/app/api/__pycache__/deps.cpython-313.pyc differ diff --git a/Backend/app/api/auth.py b/Backend/app/api/auth.py new file mode 100644 index 0000000..4cbda99 --- /dev/null +++ b/Backend/app/api/auth.py @@ -0,0 +1,62 @@ +from typing import Any +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordRequestForm +from sqlalchemy.orm import Session +from app.api import deps +from app.core import security +from app.models.user import User +from app.schemas.user import UserCreate, User as UserSchema +from app.schemas.token import Token + +router = APIRouter() + +@router.post("/register", response_model=UserSchema) +def register( + *, + db: Session = Depends(deps.get_db), + user_in: UserCreate, +) -> Any: + """ + Create new user. + """ + user = db.query(User).filter(User.email == user_in.email).first() + if user: + raise HTTPException( + status_code=400, + detail="The user with this username already exists in the system.", + ) + + hashed_password = security.get_password_hash(user_in.password) + db_user = User( + email=user_in.email, + hashed_password=hashed_password, + is_active=user_in.is_active, + is_superuser=user_in.is_superuser, + ) + db.add(db_user) + db.commit() + db.refresh(db_user) + return db_user + +@router.post("/login", response_model=Token) +def login_access_token( + db: Session = Depends(deps.get_db), form_data: OAuth2PasswordRequestForm = Depends() +) -> Any: + """ + OAuth2 compatible token login, get an access token for future requests + """ + user = db.query(User).filter(User.email == form_data.username).first() + if not user or not security.verify_password(form_data.password, user.hashed_password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect email or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + elif not user.is_active: + raise HTTPException(status_code=400, detail="Inactive user") + + access_token = security.create_access_token(subject=user.email) + return { + "access_token": access_token, + "token_type": "bearer", + } diff --git a/Backend/app/api/deps.py b/Backend/app/api/deps.py new file mode 100644 index 0000000..67e9d26 --- /dev/null +++ b/Backend/app/api/deps.py @@ -0,0 +1,42 @@ +from typing import Generator, Optional +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from jose import jwt, JWTError +from sqlalchemy.orm import Session +from app.core.config import settings +from app.database.session import SessionLocal +from app.models.user import User +from app.schemas.token import TokenData + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{settings.API_V1_STR}/auth/login") + +def get_db() -> Generator: + try: + db = SessionLocal() + yield db + finally: + db.close() + +def get_current_user( + db: Session = Depends(get_db), token: str = Depends(oauth2_scheme) +) -> User: + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = jwt.decode( + token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM] + ) + email: str = payload.get("sub") + if email is None: + raise credentials_exception + token_data = TokenData(email=email) + except JWTError: + raise credentials_exception + + user = db.query(User).filter(User.email == token_data.email).first() + if user is None: + raise credentials_exception + return user diff --git a/Backend/app/core/__pycache__/__init__.cpython-313.pyc b/Backend/app/core/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..88830ec Binary files /dev/null and b/Backend/app/core/__pycache__/__init__.cpython-313.pyc differ diff --git a/Backend/app/core/__pycache__/config.cpython-313.pyc b/Backend/app/core/__pycache__/config.cpython-313.pyc new file mode 100644 index 0000000..6782fb1 Binary files /dev/null and b/Backend/app/core/__pycache__/config.cpython-313.pyc differ diff --git a/Backend/app/core/__pycache__/security.cpython-313.pyc b/Backend/app/core/__pycache__/security.cpython-313.pyc new file mode 100644 index 0000000..4a97ac9 Binary files /dev/null and b/Backend/app/core/__pycache__/security.cpython-313.pyc differ diff --git a/Backend/app/core/config.py b/Backend/app/core/config.py new file mode 100644 index 0000000..5414eed --- /dev/null +++ b/Backend/app/core/config.py @@ -0,0 +1,16 @@ +from pydantic_settings import BaseSettings +from typing import Optional + +class Settings(BaseSettings): + PROJECT_NAME: str = "JobmateAI Backend" + API_V1_STR: str = "/api/v1" + SECRET_KEY: str = "YOUR_SECRET_KEY_HERE_PLEASE_CHANGE_IT" # TODO: Change this in production + ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8 # 8 days + ALGORITHM: str = "HS256" + + SQLALCHEMY_DATABASE_URI: Optional[str] = "sqlite:///./sql_app.db" + + class Config: + case_sensitive = True + +settings = Settings() diff --git a/Backend/app/core/security.py b/Backend/app/core/security.py new file mode 100644 index 0000000..cdb8e13 --- /dev/null +++ b/Backend/app/core/security.py @@ -0,0 +1,23 @@ +from datetime import datetime, timedelta +from typing import Any, Union, Optional +from jose import jwt +from passlib.context import CryptContext +from app.core.config import settings + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +def create_access_token(subject: Union[str, Any], expires_delta: Optional[timedelta] = None) -> str: + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + + to_encode = {"exp": expire, "sub": str(subject)} + encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + return encoded_jwt + +def verify_password(plain_password: str, hashed_password: str) -> bool: + return pwd_context.verify(plain_password, hashed_password) + +def get_password_hash(password: str) -> str: + return pwd_context.hash(password) diff --git a/Backend/app/database/__pycache__/__init__.cpython-313.pyc b/Backend/app/database/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..9516d58 Binary files /dev/null and b/Backend/app/database/__pycache__/__init__.cpython-313.pyc differ diff --git a/Backend/app/database/__pycache__/base.cpython-313.pyc b/Backend/app/database/__pycache__/base.cpython-313.pyc new file mode 100644 index 0000000..ff90b30 Binary files /dev/null and b/Backend/app/database/__pycache__/base.cpython-313.pyc differ diff --git a/Backend/app/database/__pycache__/session.cpython-313.pyc b/Backend/app/database/__pycache__/session.cpython-313.pyc new file mode 100644 index 0000000..b855783 Binary files /dev/null and b/Backend/app/database/__pycache__/session.cpython-313.pyc differ diff --git a/Backend/app/database/base.py b/Backend/app/database/base.py new file mode 100644 index 0000000..670a4e1 --- /dev/null +++ b/Backend/app/database/base.py @@ -0,0 +1,12 @@ +from typing import Any +from sqlalchemy.ext.declarative import as_declarative, declared_attr + +@as_declarative() +class Base: + id: Any + __name__: str + + # Generate __tablename__ automatically + @declared_attr + def __tablename__(cls) -> str: + return cls.__name__.lower() diff --git a/Backend/app/database/session.py b/Backend/app/database/session.py new file mode 100644 index 0000000..760daf5 --- /dev/null +++ b/Backend/app/database/session.py @@ -0,0 +1,8 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from app.core.config import settings + +engine = create_engine( + settings.SQLALCHEMY_DATABASE_URI, connect_args={"check_same_thread": False} +) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) diff --git a/Backend/app/main.py b/Backend/app/main.py index e69de29..e224d31 100644 --- a/Backend/app/main.py +++ b/Backend/app/main.py @@ -0,0 +1,16 @@ +from fastapi import FastAPI +from app.core.config import settings +from app.api import auth +from app.database.base import Base +from app.database.session import engine + +# Create database tables +Base.metadata.create_all(bind=engine) + +app = FastAPI(title=settings.PROJECT_NAME) + +app.include_router(auth.router, prefix=f"{settings.API_V1_STR}/auth", tags=["auth"]) + +@app.get("/") +def read_root(): + return {"message": "Welcome to JobmateAI Backend"} diff --git a/Backend/app/models/__pycache__/__init__.cpython-313.pyc b/Backend/app/models/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..2259b16 Binary files /dev/null and b/Backend/app/models/__pycache__/__init__.cpython-313.pyc differ diff --git a/Backend/app/models/__pycache__/user.cpython-313.pyc b/Backend/app/models/__pycache__/user.cpython-313.pyc new file mode 100644 index 0000000..7d0f14b Binary files /dev/null and b/Backend/app/models/__pycache__/user.cpython-313.pyc differ diff --git a/Backend/app/models/user.py b/Backend/app/models/user.py new file mode 100644 index 0000000..2f5cd0b --- /dev/null +++ b/Backend/app/models/user.py @@ -0,0 +1,9 @@ +from sqlalchemy import Boolean, Column, Integer, String +from app.database.base import Base + +class User(Base): + id = Column(Integer, primary_key=True, index=True) + email = Column(String, unique=True, index=True) + hashed_password = Column(String) + is_active = Column(Boolean, default=True) + is_superuser = Column(Boolean, default=False) diff --git a/Backend/app/schemas/__pycache__/__init__.cpython-313.pyc b/Backend/app/schemas/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..68af432 Binary files /dev/null and b/Backend/app/schemas/__pycache__/__init__.cpython-313.pyc differ diff --git a/Backend/app/schemas/__pycache__/token.cpython-313.pyc b/Backend/app/schemas/__pycache__/token.cpython-313.pyc new file mode 100644 index 0000000..e17995a Binary files /dev/null and b/Backend/app/schemas/__pycache__/token.cpython-313.pyc differ diff --git a/Backend/app/schemas/__pycache__/user.cpython-313.pyc b/Backend/app/schemas/__pycache__/user.cpython-313.pyc new file mode 100644 index 0000000..05e0e1c Binary files /dev/null and b/Backend/app/schemas/__pycache__/user.cpython-313.pyc differ diff --git a/Backend/app/schemas/token.py b/Backend/app/schemas/token.py new file mode 100644 index 0000000..cbd8894 --- /dev/null +++ b/Backend/app/schemas/token.py @@ -0,0 +1,9 @@ +from typing import Optional +from pydantic import BaseModel + +class Token(BaseModel): + access_token: str + token_type: str + +class TokenData(BaseModel): + email: Optional[str] = None diff --git a/Backend/app/schemas/user.py b/Backend/app/schemas/user.py new file mode 100644 index 0000000..84ae085 --- /dev/null +++ b/Backend/app/schemas/user.py @@ -0,0 +1,17 @@ +from typing import Optional +from pydantic import BaseModel, EmailStr + +class UserBase(BaseModel): + email: Optional[EmailStr] = None + is_active: Optional[bool] = True + is_superuser: bool = False + +class UserCreate(UserBase): + email: EmailStr + password: str + +class User(UserBase): + id: int + + class Config: + from_attributes = True diff --git a/Backend/requirements.txt b/Backend/requirements.txt index 8b13789..035d20a 100644 --- a/Backend/requirements.txt +++ b/Backend/requirements.txt @@ -1 +1,12 @@ - +fastapi +uvicorn +sqlalchemy +alembic +passlib[bcrypt] +python-jose[cryptography] +python-multipart +pydantic-settings +httpx +pytest +email-validator +bcrypt==4.0.1 diff --git a/Backend/sql_app.db b/Backend/sql_app.db new file mode 100644 index 0000000..61a54cd Binary files /dev/null and b/Backend/sql_app.db differ diff --git a/Backend/test.db b/Backend/test.db new file mode 100644 index 0000000..3cc300d Binary files /dev/null and b/Backend/test.db differ diff --git a/Backend/tests/__pycache__/__init__.cpython-313.pyc b/Backend/tests/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..c68dc41 Binary files /dev/null and b/Backend/tests/__pycache__/__init__.cpython-313.pyc differ diff --git a/Backend/tests/__pycache__/test_auth.cpython-313-pytest-9.0.1.pyc b/Backend/tests/__pycache__/test_auth.cpython-313-pytest-9.0.1.pyc new file mode 100644 index 0000000..5b2a68b Binary files /dev/null and b/Backend/tests/__pycache__/test_auth.cpython-313-pytest-9.0.1.pyc differ diff --git a/Backend/tests/test_auth.py b/Backend/tests/test_auth.py new file mode 100644 index 0000000..499fd1b --- /dev/null +++ b/Backend/tests/test_auth.py @@ -0,0 +1,53 @@ +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from app.main import app +from app.database.base import Base +from app.api.deps import get_db + +SQLALCHEMY_DATABASE_URL = "sqlite:///./test.db" + +engine = create_engine( + SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False} +) +TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +Base.metadata.create_all(bind=engine) + +def override_get_db(): + try: + db = TestingSessionLocal() + yield db + finally: + db.close() + +app.dependency_overrides[get_db] = override_get_db + +client = TestClient(app) + +def test_create_user(): + response = client.post( + "/api/v1/auth/register", + json={"email": "test@example.com", "password": "password123", "is_active": True}, + ) + assert response.status_code == 200 + data = response.json() + assert data["email"] == "test@example.com" + assert "id" in data + +def test_login_user(): + response = client.post( + "/api/v1/auth/login", + data={"username": "test@example.com", "password": "password123"}, + ) + assert response.status_code == 200 + data = response.json() + assert "access_token" in data + assert data["token_type"] == "bearer" + +def test_login_wrong_password(): + response = client.post( + "/api/v1/auth/login", + data={"username": "test@example.com", "password": "wrongpassword"}, + ) + assert response.status_code == 401