Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions api-server/app/auth/controllers/create_token.py
Original file line number Diff line number Diff line change
@@ -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
43 changes: 43 additions & 0 deletions api-server/app/auth/middlewares/get_token_claims.py
Original file line number Diff line number Diff line change
@@ -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)
9 changes: 9 additions & 0 deletions api-server/app/auth/models/token_claims.py
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions api-server/app/auth/models/token_request.py
Original file line number Diff line number Diff line change
@@ -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.")
4 changes: 4 additions & 0 deletions api-server/app/auth/models/token_response.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from pydantic import BaseModel, Field

class TokenResponse(BaseModel):
access_token: str = Field(..., description="Access token for the user")
17 changes: 17 additions & 0 deletions api-server/app/auth/router.py
Original file line number Diff line number Diff line change
@@ -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)
14 changes: 11 additions & 3 deletions api-server/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -16,13 +17,15 @@
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
from .project.models.project_database_model import Project

# injecting routers
from .user.routes import router as user_router
from .auth.router import router as auth_router

load_dotenv()

Expand All @@ -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)
# injecting routers
app.include_router(user_router)
app.include_router(auth_router)
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
)
17 changes: 10 additions & 7 deletions api-server/app/user/controllers/create_user.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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,
Expand All @@ -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")
logger.error("Error creating user", error=e, x_exosphere_request_id=x_exosphere_request_id)
raise e
4 changes: 2 additions & 2 deletions api-server/app/user/models/user_database_model.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)")

Expand Down
1 change: 1 addition & 0 deletions api-server/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 11 additions & 0 deletions api-server/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.