diff --git a/api-server/app/auth/controllers/create_token.py b/api-server/app/auth/controllers/create_token.py new file mode 100644 index 00000000..bebd151b --- /dev/null +++ b/api-server/app/auth/controllers/create_token.py @@ -0,0 +1,57 @@ +import os +import jwt +from datetime import datetime, timedelta +from starlette.responses import JSONResponse + +from ..models.token_request import TokenRequest +from ..models.token_response import TokenResponse +from ..models.token_claims import TokenClaims + +from app.singletons.logs_manager import LogsManager + +from app.user.models.user_database_model import User + +logger = LogsManager().get_logger() + +JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY") +if not JWT_SECRET_KEY: + raise ValueError("JWT_SECRET_KEY environment variable is not set or is empty.") +JWT_ALGORITHM = "HS256" +JWT_EXPIRES_IN = 3600 # 1 hour + + +async def create_token(request: TokenRequest, x_exosphere_request_id: str) -> TokenResponse: + + try: + logger.info("Finding user", x_exosphere_request_id=x_exosphere_request_id) + + user = await User.find_one(User.identifier == request.identifier) + + if not user: + logger.error("User not found", x_exosphere_request_id=x_exosphere_request_id) + return JSONResponse(status_code=404, content={"success": False, "detail": "User not found"}) + + if not user.verify_credential(request.credential): + logger.error("Invalid credential", x_exosphere_request_id=x_exosphere_request_id) + return JSONResponse(status_code=401, content={"success": False, "detail": "Invalid credential"}) + + logger.info("User found and credential verified", x_exosphere_request_id=x_exosphere_request_id) + + logger.info("User is a super admin", x_exosphere_request_id=x_exosphere_request_id) + + token_claims = TokenClaims( + user_id=str(user.id), + user_name=user.name, + user_type=user.type, + verification_status=user.verification_status, + status=user.status, + exp=int((datetime.now() + timedelta(seconds=JWT_EXPIRES_IN)).timestamp()) + ) + + return TokenResponse( + access_token=jwt.encode(token_claims.model_dump(), JWT_SECRET_KEY, algorithm=JWT_ALGORITHM) + ) + + except Exception as e: + logger.error("Error creating token", error=e, x_exosphere_request_id=x_exosphere_request_id) + raise e \ No newline at end of file diff --git a/api-server/app/auth/middlewares/get_token_claims.py b/api-server/app/auth/middlewares/get_token_claims.py new file mode 100644 index 00000000..efef3c32 --- /dev/null +++ b/api-server/app/auth/middlewares/get_token_claims.py @@ -0,0 +1,43 @@ +import jwt +import os + +from starlette.middleware.base import BaseHTTPMiddleware +from app.singletons.logs_manager import LogsManager +from starlette.requests import Request +from starlette.responses import JSONResponse + +from ..models.token_claims import TokenClaims + +logger = LogsManager().get_logger() + +JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY") +if not JWT_SECRET_KEY: + raise ValueError("JWT_SECRET_KEY environment variable is not set or is empty.") +JWT_ALGORITHM = "HS256" + + +class GetTokenClaimsMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + + token = request.headers.get("Authorization") + + if token: + if not token.startswith("Bearer "): + logger.error("Invalid token format", x_exosphere_request_id=getattr(request.state, 'x_exosphere_request_id', None)) + return JSONResponse(status_code=401, content={"message": "Invalid token format", "success": False}) + + try: + token_claims = jwt.decode(token.split(" ")[1], JWT_SECRET_KEY, algorithms=[JWT_ALGORITHM]) + request.state.token_claims = TokenClaims(**token_claims) + logger.info("Token claims decoded", x_exosphere_request_id=getattr(request.state, 'x_exosphere_request_id', None), user_id=request.state.token_claims.user_id) + + except Exception as e: + logger.error("Error decoding token", error=e, x_exosphere_request_id=getattr(request.state, 'x_exosphere_request_id', None)) + + return JSONResponse(status_code=401, content={"message": "Invalid token", "success": False}) + + else: + logger.error("No token provided", x_exosphere_request_id=getattr(request.state, 'x_exosphere_request_id', None)) + request.state.token_claims = None + + return await call_next(request) \ No newline at end of file diff --git a/api-server/app/auth/models/token_claims.py b/api-server/app/auth/models/token_claims.py new file mode 100644 index 00000000..3283ea65 --- /dev/null +++ b/api-server/app/auth/models/token_claims.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel + +class TokenClaims(BaseModel): + user_id: str + user_name: str + user_type: str + verification_status: str + status: str + exp: int \ No newline at end of file diff --git a/api-server/app/auth/models/token_request.py b/api-server/app/auth/models/token_request.py new file mode 100644 index 00000000..bc6deda0 --- /dev/null +++ b/api-server/app/auth/models/token_request.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel, Field + +class TokenRequest(BaseModel): + identifier: str = Field(..., description="Identifier of the user, could be an email, phone, username, etc.") + credential: str = Field(..., description="Credential of the user, could be a password, api secret, etc.") \ No newline at end of file diff --git a/api-server/app/auth/models/token_response.py b/api-server/app/auth/models/token_response.py new file mode 100644 index 00000000..a272f968 --- /dev/null +++ b/api-server/app/auth/models/token_response.py @@ -0,0 +1,4 @@ +from pydantic import BaseModel, Field + +class TokenResponse(BaseModel): + access_token: str = Field(..., description="Access token for the user") \ No newline at end of file diff --git a/api-server/app/auth/router.py b/api-server/app/auth/router.py new file mode 100644 index 00000000..c135c9f1 --- /dev/null +++ b/api-server/app/auth/router.py @@ -0,0 +1,17 @@ +from fastapi import APIRouter, status, Request + +from .controllers.create_token import create_token +from .models.token_request import TokenRequest +from .models.token_response import TokenResponse + +router = APIRouter(prefix="/auth", tags=["auth"]) + +@router.post( + "/token", + response_model=TokenResponse, + status_code=status.HTTP_200_OK, + response_description="Token created successfully" +) +async def create_token_route(body: TokenRequest, request: Request): + x_exosphere_request_id = getattr(request.state, "x_exosphere_request_id", None) + return await create_token(body, x_exosphere_request_id) \ No newline at end of file diff --git a/api-server/app/main.py b/api-server/app/main.py index 2374a828..ff450a47 100644 --- a/api-server/app/main.py +++ b/api-server/app/main.py @@ -7,6 +7,7 @@ from contextlib import asynccontextmanager from dotenv import load_dotenv from motor.motor_asyncio import AsyncIOMotorClient +from fastapi.security import OAuth2PasswordBearer # injecting singletons from .singletons.logs_manager import LogsManager @@ -16,6 +17,7 @@ UnhandledExceptionsMiddleware, ) from .middlewares.request_id_middleware import RequestIdMiddleware +from .auth.middlewares.get_token_claims import GetTokenClaimsMiddleware # injecting databases from .user.models.user_database_model import User @@ -23,6 +25,7 @@ # injecting routers from .user.routes import router as user_router +from .auth.router import router as auth_router load_dotenv() @@ -48,16 +51,21 @@ async def lifespan(app: FastAPI): app = FastAPI(lifespan=lifespan) +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/token") + # this middleware should be the first one +app.add_middleware(GetTokenClaimsMiddleware) + app.add_middleware(RequestIdMiddleware) -# this middleware should be the last one app.add_middleware(UnhandledExceptionsMiddleware) + @app.get("/health-check") def health() -> dict: return {"message": "OK"} - -app.include_router(user_router) \ No newline at end of file +# injecting routers +app.include_router(user_router) +app.include_router(auth_router) diff --git a/api-server/app/middlewares/unhandled_exceptions_middleware.py b/api-server/app/middlewares/unhandled_exceptions_middleware.py index afb1f951..3e1878ff 100644 --- a/api-server/app/middlewares/unhandled_exceptions_middleware.py +++ b/api-server/app/middlewares/unhandled_exceptions_middleware.py @@ -25,6 +25,6 @@ async def dispatch(self, request: Request, call_next): status_code=500, content={ "success": False, - "detail": "internal server error, please reach out to support team at support@exosphere.com", + "detail": "internal server error, please reach out to support team at nivedit@exosphere.host", }, ) diff --git a/api-server/app/user/controllers/create_user.py b/api-server/app/user/controllers/create_user.py index dfc3a033..c705cac8 100644 --- a/api-server/app/user/controllers/create_user.py +++ b/api-server/app/user/controllers/create_user.py @@ -1,4 +1,5 @@ -from fastapi import HTTPException +from pymongo.errors import DuplicateKeyError +from starlette.responses import JSONResponse from ..models.create_user_request import CreateUserRequest from ..models.create_user_response import CreateUserResponse @@ -19,10 +20,9 @@ async def create_user(request: CreateUserRequest, x_exosphere_request_id: str) - identifier=request.identifier, credential=request.credential ) - await new_user.save() + await new_user.insert() logger.info("User created", x_exosphere_request_id=x_exosphere_request_id) - return CreateUserResponse( id=str(new_user.id), name=new_user.name, @@ -33,8 +33,11 @@ async def create_user(request: CreateUserRequest, x_exosphere_request_id: str) - created_at=new_user.created_at, updated_at=new_user.updated_at ) - + + except DuplicateKeyError as e: + logger.error("Error creating user", error=e, x_exosphere_request_id=x_exosphere_request_id) + return JSONResponse(status_code=400, content={"success": False, "detail": "User already exists"}) + except Exception as e: - logger.error("Error creating user error", error=e, x_exosphere_request_id=x_exosphere_request_id) - - raise HTTPException(status_code=500, detail="Error creating user") \ No newline at end of file + logger.error("Error creating user", error=e, x_exosphere_request_id=x_exosphere_request_id) + raise e \ No newline at end of file diff --git a/api-server/app/user/models/user_database_model.py b/api-server/app/user/models/user_database_model.py index c8ae38f2..7920797d 100644 --- a/api-server/app/user/models/user_database_model.py +++ b/api-server/app/user/models/user_database_model.py @@ -1,6 +1,6 @@ import bcrypt from datetime import datetime -from beanie import Document, before_event, Replace, Save +from beanie import Document, before_event, Replace, Save, Indexed from pydantic import Field from .user_type_enum import UserTypeEnum from .verification_status_enum import VerificationStatusEnum @@ -13,7 +13,7 @@ class User(Document): type: UserTypeEnum = Field(..., description="Type of the user, human, api, etc. (enum)") - identifier: str = Field(..., description="Unique identifier for the user, could be an email, phone, username, etc.", unique=True) + identifier: Indexed(str, unique=True) = Field(..., description="Unique identifier for the user, could be an email, phone, username, etc.") verification_status: VerificationStatusEnum = Field(default=VerificationStatusEnum.NOT_VERIFIED, description="Verification status of the user, verified, not_verified, blocked, deleted, not_required (enum)") diff --git a/api-server/pyproject.toml b/api-server/pyproject.toml index a45a6a23..0d0f1e90 100644 --- a/api-server/pyproject.toml +++ b/api-server/pyproject.toml @@ -8,6 +8,7 @@ dependencies = [ "bcrypt>=4.3.0", "beanie>=1.30.0", "fastapi>=0.115.14", + "pyjwt>=2.10.1", "python-dotenv>=1.1.1", "structlog>=25.4.0", "uvicorn>=0.35.0", diff --git a/api-server/uv.lock b/api-server/uv.lock index be6ca87a..600606c5 100644 --- a/api-server/uv.lock +++ b/api-server/uv.lock @@ -33,6 +33,7 @@ dependencies = [ { name = "bcrypt" }, { name = "beanie" }, { name = "fastapi" }, + { name = "pyjwt" }, { name = "python-dotenv" }, { name = "structlog" }, { name = "uvicorn" }, @@ -48,6 +49,7 @@ requires-dist = [ { name = "bcrypt", specifier = ">=4.3.0" }, { name = "beanie", specifier = ">=1.30.0" }, { name = "fastapi", specifier = ">=0.115.14" }, + { name = "pyjwt", specifier = ">=2.10.1" }, { name = "python-dotenv", specifier = ">=1.1.1" }, { name = "structlog", specifier = ">=25.4.0" }, { name = "uvicorn", specifier = ">=0.35.0" }, @@ -265,6 +267,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, ] +[[package]] +name = "pyjwt" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, +] + [[package]] name = "pymongo" version = "4.13.2"