From d2199acd497c4e3a97185995cb62e7bf3391aca2 Mon Sep 17 00:00:00 2001 From: KevinRawal Date: Sat, 26 Jul 2025 10:57:35 +0530 Subject: [PATCH 1/7] update with main --- app/api/main.py | 3 +- app/api/routes/auth.py | 62 ++++++++++++++++++++++++++++++++++++++++++ app/settings.py | 11 ++++++++ requirements.txt | 1 + 4 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 app/api/routes/auth.py diff --git a/app/api/main.py b/app/api/main.py index 9a7279e..d5ce163 100644 --- a/app/api/main.py +++ b/app/api/main.py @@ -1,7 +1,8 @@ from fastapi import APIRouter -from app.api.routes import user +from app.api.routes import auth, user api_router = APIRouter() api_router.include_router(user.router, prefix="/users", tags=["users"]) +api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) diff --git a/app/api/routes/auth.py b/app/api/routes/auth.py new file mode 100644 index 0000000..0c6553d --- /dev/null +++ b/app/api/routes/auth.py @@ -0,0 +1,62 @@ +from authlib.integrations.starlette_client import OAuth, OAuthError +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import JSONResponse +from starlette.config import Config + +from app.settings import settings +from app.utils import auth + +router = APIRouter() + +# OAuth setup +oauth = OAuth( + Config( + environ={ + "GITHUB_CLIENT_ID": settings.GITHUB_CLIENT_ID, + "GITHUB_CLIENT_SECRET": settings.GITHUB_CLIENT_SECRET, + } + ) +) +github = oauth.register( + name="github", + client_id=settings.GITHUB_CLIENT_ID, + client_secret=settings.GITHUB_CLIENT_SECRET, + access_token_url="https://github.com/login/oauth/access_token", + access_token_params=None, + authorize_url="https://github.com/login/oauth/authorize", + authorize_params=None, + api_base_url="https://api.github.com/", + client_kwargs={"scope": "user:email"}, +) + + +@router.get("/github/login") +def login_via_github(request: Request): + redirect_uri = request.url_for("auth_github_callback") + return github.authorize_redirect(request, redirect_uri) + + +@router.get("/github/callback") +async def auth_github_callback(request: Request): + try: + token = await github.authorize_access_token(request) + resp = await github.get("user", token=token) + profile = resp.json() + email = profile.get("email") + if not email: + # fetch primary email + emails_resp = await github.get("user/emails", token=token) + emails = emails_resp.json() + primary_email = next( + (e["email"] for e in emails if e.get("primary") and e.get("verified")), + None, + ) + email = primary_email + if not email: + raise HTTPException(status_code=400, detail="GitHub email not found") + # Issue a JWT token for the user + user_data = {"sub": email, "github_id": profile["id"]} + jwt_token = auth.create_access_token(user_data) + return JSONResponse({"access_token": jwt_token, "token_type": "bearer"}) + except OAuthError as e: + return JSONResponse({"error": str(e)}, status_code=400) diff --git a/app/settings.py b/app/settings.py index 958d866..e0f42e6 100644 --- a/app/settings.py +++ b/app/settings.py @@ -24,6 +24,17 @@ class Settings(BaseSettings): DEBUG: bool = False VERSION: str = "1.0.0" + # Email + FROM_EMAIL: str + SMTP_PASSWORD: str + SMTP_USERNAME: str + SMTP_SERVER: str = "smtp.gmail.com" + SMTP_PORT: int = 587 + + # github + GITHUB_CLIENT_ID: str + GITHUB_CLIENT_SECRET: str + class Config: """Configuration for the settings.""" diff --git a/requirements.txt b/requirements.txt index 658ba04..e7951dc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,3 +11,4 @@ pydantic-settings python-multipart passlib[bcrypt] python-jose +Authlib From 7ee6d7a209f19227a9430bd19fafc6a073c3ffc5 Mon Sep 17 00:00:00 2001 From: KevinRawal Date: Sun, 27 Jul 2025 00:44:53 +0530 Subject: [PATCH 2/7] integrated github auth --- app/api/routes/auth.py | 55 +++++++++++++++++++++++++++++---------- app/api/routes/user.py | 19 ++++++++++---- app/main.py | 29 +++++++++++++++++++++ app/models/otp.py | 1 - app/services/user.py | 59 ++++++++++++++++++++++++++++++------------ app/utils/email.py | 8 +++--- requirements.txt | 2 ++ 7 files changed, 133 insertions(+), 40 deletions(-) diff --git a/app/api/routes/auth.py b/app/api/routes/auth.py index 0c6553d..1ebcff4 100644 --- a/app/api/routes/auth.py +++ b/app/api/routes/auth.py @@ -1,14 +1,19 @@ +from datetime import datetime + from authlib.integrations.starlette_client import OAuth, OAuthError -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import JSONResponse +from sqlalchemy.orm import Session from starlette.config import Config +from app.config.database import get_db +from app.models.user import AuthType, User from app.settings import settings from app.utils import auth router = APIRouter() -# OAuth setup +# TODO: Move this to a separate config file oauth = OAuth( Config( environ={ @@ -31,32 +36,54 @@ @router.get("/github/login") -def login_via_github(request: Request): +async def login_via_github(request: Request): + """Initiates the GitHub OAuth login flow.""" redirect_uri = request.url_for("auth_github_callback") - return github.authorize_redirect(request, redirect_uri) + return await github.authorize_redirect(request, redirect_uri) @router.get("/github/callback") -async def auth_github_callback(request: Request): +async def auth_github_callback(request: Request, db: Session = Depends(get_db)): + """Handles the GitHub OAuth callback and logs in or registers the user.""" try: token = await github.authorize_access_token(request) resp = await github.get("user", token=token) profile = resp.json() email = profile.get("email") + name = profile.get("name") or profile.get("login") + provider_id = str(profile.get("id")) + + # If email is not public, fetch from emails endpoint if not email: - # fetch primary email emails_resp = await github.get("user/emails", token=token) emails = emails_resp.json() - primary_email = next( + email = next( (e["email"] for e in emails if e.get("primary") and e.get("verified")), None, ) - email = primary_email - if not email: - raise HTTPException(status_code=400, detail="GitHub email not found") - # Issue a JWT token for the user - user_data = {"sub": email, "github_id": profile["id"]} - jwt_token = auth.create_access_token(user_data) - return JSONResponse({"access_token": jwt_token, "token_type": "bearer"}) + if not email: + raise HTTPException( + status_code=400, detail="GitHub account has no accessible email." + ) + + user = db.query(User).filter(User.email == email).first() + if not user: + # TODO: Use existing create user logic + user = User( + email=email, + name=name, + provider_id=provider_id, + auth_type=AuthType.GITHUB, + is_active=True, + created_at=datetime.utcnow(), + ) + db.add(user) + db.commit() + db.refresh(user) + + access_token = auth.create_access_token( + {"sub": str(user.id), "email": user.email} + ) + return JSONResponse({"access_token": access_token, "token_type": "bearer"}) except OAuthError as e: return JSONResponse({"error": str(e)}, status_code=400) diff --git a/app/api/routes/user.py b/app/api/routes/user.py index 72dc715..fb032d5 100644 --- a/app/api/routes/user.py +++ b/app/api/routes/user.py @@ -1,12 +1,15 @@ - from fastapi import APIRouter, Depends from fastapi.security import OAuth2PasswordRequestForm from sqlalchemy.orm import Session from app.config.database import get_db from app.schemas.users import OTPVerifyRequest, Token, UserCreate, UserResponse -from app.services.user import (get_current_user, login_user, register_with_otp, - verify_otp_and_create_user) +from app.services.user import ( + get_current_user, + login_user, + register_with_otp, + verify_otp_and_create_user, +) router = APIRouter() @@ -29,6 +32,12 @@ def login( return login_user(db, form_data.username, form_data.password) -@router.get("/me", response_model=str) +@router.get("/me") def read_users_me(current_user=Depends(get_current_user)): - return current_user + if hasattr(current_user, "id"): + return { + "id": current_user.id, + "email": current_user.email, + "name": current_user.name, + } + return {"user": current_user} diff --git a/app/main.py b/app/main.py index 58ca63c..4105d7a 100644 --- a/app/main.py +++ b/app/main.py @@ -1,11 +1,40 @@ # from functools import lru_cache from fastapi import FastAPI +from fastapi.openapi.utils import get_openapi +from starlette.middleware.sessions import SessionMiddleware from app.api.main import api_router from app.settings import settings app = FastAPI() + +def custom_openapi(): + if app.openapi_schema: + return app.openapi_schema + openapi_schema = get_openapi( + title="JamAndFlow API", + version="1.0.0", + description="JamAndFlow API", + routes=app.routes, + ) + openapi_schema["components"]["securitySchemes"] = { + "BearerAuth": {"type": "http", "scheme": "bearer", "bearerFormat": "JWT"} + } + for path in openapi_schema["paths"].values(): + for method in path.values(): + method.setdefault("security", []).append({"BearerAuth": []}) + app.openapi_schema = openapi_schema + return app.openapi_schema + + +app.openapi = custom_openapi + +# This line adds session middleware to the FastAPI application. +# It allows the application to manage user sessions, +# which is useful for authentication and state management. +app.add_middleware(SessionMiddleware, secret_key=settings.SECRET_KEY) + # Uncomment the following lines if you want to use caching for settings # @lru_cache() # def get_settings(): diff --git a/app/models/otp.py b/app/models/otp.py index 761c763..d2f221f 100644 --- a/app/models/otp.py +++ b/app/models/otp.py @@ -1,4 +1,3 @@ - from datetime import datetime from sqlalchemy import Column, DateTime, Integer, String diff --git a/app/services/user.py b/app/services/user.py index 52fa54e..30b6348 100644 --- a/app/services/user.py +++ b/app/services/user.py @@ -3,11 +3,13 @@ from datetime import datetime, timedelta from fastapi import Depends, HTTPException, status -from fastapi.security import OAuth2PasswordBearer +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from jose import JWTError, jwt from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session +from sqlalchemy.orm import Session as _Session +from app.config.database import get_db from app.models.otp import OTP from app.models.user import AuthType, User from app.schemas.users import UserCreate @@ -15,7 +17,7 @@ from app.utils.auth import create_access_token, hash_password, verify_password from app.utils.email import send_otp_email -oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/users/login") +security = HTTPBearer() def create_user(db: Session, user_in: UserCreate) -> User: @@ -63,12 +65,17 @@ def login_user(db: Session, email: str, password: str): return {"access_token": access_token, "token_type": "bearer"} -def get_current_user(token: str = Depends(oauth2_scheme)): +def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security), + db: _Session = Depends(get_db), +): + token = credentials.credentials 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] @@ -76,28 +83,45 @@ def get_current_user(token: str = Depends(oauth2_scheme)): user_id: str = payload.get("sub") if user_id is None: raise credentials_exception - # Optionally, fetch user from DB here - return user_id + user = db.query(User).filter(User.id == user_id).first() + if user is None: + raise credentials_exception + return user except JWTError: - raise credentials_exception + raise JWTError("JWT validation failed") +# TODO: move this function to a utils module or similar def generate_otp(): return str(random.randint(100000, 999999)) +# TODO: move this function to a utils module or similar def validate_strong_password(password: str): # At least 8 chars, one uppercase, one lowercase, one digit, one special char if not password or len(password) < 8: - raise HTTPException(status_code=400, detail="Password must be at least 8 characters long.") + raise HTTPException( + status_code=400, detail="Password must be at least 8 characters long." + ) if not re.search(r"[A-Z]", password): - raise HTTPException(status_code=400, detail="Password must contain at least one uppercase letter.") + raise HTTPException( + status_code=400, + detail="Password must contain at least one uppercase letter.", + ) if not re.search(r"[a-z]", password): - raise HTTPException(status_code=400, detail="Password must contain at least one lowercase letter.") + raise HTTPException( + status_code=400, + detail="Password must contain at least one lowercase letter.", + ) if not re.search(r"\d", password): - raise HTTPException(status_code=400, detail="Password must contain at least one digit.") + raise HTTPException( + status_code=400, detail="Password must contain at least one digit." + ) if not re.search(r"[!@#$%^&*(),.?\":{}|<>]", password): - raise HTTPException(status_code=400, detail="Password must contain at least one special character.") + raise HTTPException( + status_code=400, + detail="Password must contain at least one special character.", + ) def register_with_otp(db: Session, user_in: UserCreate): @@ -116,11 +140,11 @@ def register_with_otp(db: Session, user_in: UserCreate): otp_code=otp_code, name=user_in.name, password=hashed_password, - is_active=1 if getattr(user_in, 'is_active', True) else 0, - provider_id=getattr(user_in, 'provider_id', None), - auth_type=user_in.auth_type.value if hasattr(user_in, 'auth_type') else 'local', + is_active=1 if getattr(user_in, "is_active", True) else 0, + provider_id=getattr(user_in, "provider_id", None), + auth_type=user_in.auth_type.value if hasattr(user_in, "auth_type") else "local", created_at=datetime.utcnow(), - expires_at=expires_at + expires_at=expires_at, ) db.add(otp_entry) db.commit() @@ -130,7 +154,9 @@ def register_with_otp(db: Session, user_in: UserCreate): def verify_otp_and_create_user(db: Session, email: str, otp_code: str): - otp_entry = db.query(OTP).filter(OTP.email == email, OTP.otp_code == otp_code).first() + otp_entry = ( + db.query(OTP).filter(OTP.email == email, OTP.otp_code == otp_code).first() + ) if not otp_entry or otp_entry.is_expired(): raise HTTPException(status_code=400, detail="Invalid or expired OTP") # Check if user already exists @@ -140,6 +166,7 @@ def verify_otp_and_create_user(db: Session, email: str, otp_code: str): db.commit() raise HTTPException(status_code=400, detail="User already registered.") # Create user from OTP details + # TODO: use existing user creation logic user = User( email=otp_entry.email, name=otp_entry.name, diff --git a/app/utils/email.py b/app/utils/email.py index 46caa4c..634efbf 100644 --- a/app/utils/email.py +++ b/app/utils/email.py @@ -30,10 +30,10 @@ def send_otp_email(to_email: str, otp_code: str): """ msg = MIMEMultipart() - msg['From'] = settings.FROM_EMAIL - msg['To'] = to_email - msg['Subject'] = subject - msg.attach(MIMEText(html, 'html')) + msg["From"] = settings.FROM_EMAIL + msg["To"] = to_email + msg["Subject"] = subject + msg.attach(MIMEText(html, "html")) try: with smtplib.SMTP(settings.SMTP_SERVER, 587) as server: diff --git a/requirements.txt b/requirements.txt index e7951dc..2fa8dc9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,3 +12,5 @@ python-multipart passlib[bcrypt] python-jose Authlib +itsdangerous +requests From 18f54a0603f474c6fb27db07969121e3f30d5604 Mon Sep 17 00:00:00 2001 From: KevinRawal Date: Sun, 27 Jul 2025 00:51:00 +0530 Subject: [PATCH 3/7] fixed linting --- app/api/routes/user.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/app/api/routes/user.py b/app/api/routes/user.py index fb032d5..848308d 100644 --- a/app/api/routes/user.py +++ b/app/api/routes/user.py @@ -4,12 +4,8 @@ from app.config.database import get_db from app.schemas.users import OTPVerifyRequest, Token, UserCreate, UserResponse -from app.services.user import ( - get_current_user, - login_user, - register_with_otp, - verify_otp_and_create_user, -) +from app.services.user import (get_current_user, login_user, register_with_otp, + verify_otp_and_create_user) router = APIRouter() From 0fecc9fc66dfa96b3a9f32f7a8b159bd856a8a28 Mon Sep 17 00:00:00 2001 From: Kevin Rawal <84058124+kevinrawal@users.noreply.github.com> Date: Sun, 27 Jul 2025 00:53:26 +0530 Subject: [PATCH 4/7] Potential fix for code scanning alert no. 4: Information exposure through an exception Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Signed-off-by: Kevin Rawal <84058124+kevinrawal@users.noreply.github.com> --- app/api/routes/auth.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/api/routes/auth.py b/app/api/routes/auth.py index 1ebcff4..a71acc8 100644 --- a/app/api/routes/auth.py +++ b/app/api/routes/auth.py @@ -86,4 +86,8 @@ async def auth_github_callback(request: Request, db: Session = Depends(get_db)): ) return JSONResponse({"access_token": access_token, "token_type": "bearer"}) except OAuthError as e: - return JSONResponse({"error": str(e)}, status_code=400) + # Log the detailed exception for debugging purposes + import logging + logging.error("OAuthError occurred during GitHub authentication", exc_info=e) + # Return a generic error message to the client + return JSONResponse({"error": "An error occurred during authentication. Please try again later."}, status_code=400) From 0dd0add29c414d025f226d0f0590ae069ebea923 Mon Sep 17 00:00:00 2001 From: KevinRawal Date: Sun, 27 Jul 2025 01:02:14 +0530 Subject: [PATCH 5/7] added authentication readme --- AUTHENTICATION.md | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 AUTHENTICATION.md diff --git a/AUTHENTICATION.md b/AUTHENTICATION.md new file mode 100644 index 0000000..88d398e --- /dev/null +++ b/AUTHENTICATION.md @@ -0,0 +1,43 @@ +# Authentication Guide + +This project supports authentication via both username/password and GitHub OAuth. All authenticated API endpoints require a valid JWT Bearer token. + +## 1. Username/Password Login + +1. Register a user (with OTP verification): + - `POST /api/v1/users/register` with JSON body `{ "email": ..., "name": ..., "password": ... }` + - `POST /api/v1/users/verify-otp` with JSON body `{ "email": ..., "otp_code": ... }` +2. Login: + - `POST /api/v1/users/login` with form data `username` and `password`. + - The response will include `{ "access_token": "", "token_type": "bearer" }`. + +## 2. GitHub OAuth Login + +1. Go to `/api/v1/auth/github/login` in your browser. +2. Authorize the app on GitHub. +3. You will be redirected back and receive a JSON response with `{ "access_token": "", "token_type": "bearer" }`. + +## 3. Using the Token in Swagger UI + +- Open `/docs` (Swagger UI). +- Click the "Authorize" button. +- **Paste only the JWT token** (do NOT include the `Bearer ` prefix). +- Click "Authorize". +- You can now access all authenticated endpoints. + +## 4. Using the Token in API Clients (e.g., Postman) + +- Set the `Authorization` header: + ``` + Authorization: + ``` + +## 5. Notes + +- The same JWT token format is used for both login methods. +- If you get a credentials error, ensure you are pasting only the token (not `Bearer `) in Swagger UI. +- For GitHub login, the user is auto-registered on first login if not already present. + +--- + +For more details, see the code in `app/services/user.py` and `app/api/routes/auth.py`. From 42a1aad3de5049478e1145710fb6664f2293d048 Mon Sep 17 00:00:00 2001 From: KevinRawal Date: Sun, 27 Jul 2025 13:30:31 +0530 Subject: [PATCH 6/7] refactor the auth flow --- app/api/routes/auth.py | 89 ++++++--------------------------- app/api/routes/user.py | 23 ++++----- app/config/github.py | 25 ++++++++++ app/models/user.py | 1 + app/services/auth.py | 89 +++++++++++++++++++++++++++++++++ app/services/user.py | 110 ++++++++++++----------------------------- app/utils/auth.py | 46 +++++++++++++++++ 7 files changed, 219 insertions(+), 164 deletions(-) create mode 100644 app/config/github.py create mode 100644 app/services/auth.py diff --git a/app/api/routes/auth.py b/app/api/routes/auth.py index a71acc8..719bf3f 100644 --- a/app/api/routes/auth.py +++ b/app/api/routes/auth.py @@ -1,38 +1,22 @@ -from datetime import datetime - -from authlib.integrations.starlette_client import OAuth, OAuthError -from fastapi import APIRouter, Depends, HTTPException, Request -from fastapi.responses import JSONResponse +from fastapi import APIRouter, Depends, Request +from fastapi.security import OAuth2PasswordRequestForm from sqlalchemy.orm import Session -from starlette.config import Config from app.config.database import get_db -from app.models.user import AuthType, User -from app.settings import settings -from app.utils import auth +from app.config.github import github +from app.schemas.users import Token +from app.services.auth import login_github_user, login_user router = APIRouter() -# TODO: Move this to a separate config file -oauth = OAuth( - Config( - environ={ - "GITHUB_CLIENT_ID": settings.GITHUB_CLIENT_ID, - "GITHUB_CLIENT_SECRET": settings.GITHUB_CLIENT_SECRET, - } - ) -) -github = oauth.register( - name="github", - client_id=settings.GITHUB_CLIENT_ID, - client_secret=settings.GITHUB_CLIENT_SECRET, - access_token_url="https://github.com/login/oauth/access_token", - access_token_params=None, - authorize_url="https://github.com/login/oauth/authorize", - authorize_params=None, - api_base_url="https://api.github.com/", - client_kwargs={"scope": "user:email"}, -) + +# Standard username/password login route +@router.post("/login", response_model=Token) +def login( + form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db) +): + """Simple login route using username and password.""" + return login_user(db, form_data.username, form_data.password) @router.get("/github/login") @@ -45,49 +29,4 @@ async def login_via_github(request: Request): @router.get("/github/callback") async def auth_github_callback(request: Request, db: Session = Depends(get_db)): """Handles the GitHub OAuth callback and logs in or registers the user.""" - try: - token = await github.authorize_access_token(request) - resp = await github.get("user", token=token) - profile = resp.json() - email = profile.get("email") - name = profile.get("name") or profile.get("login") - provider_id = str(profile.get("id")) - - # If email is not public, fetch from emails endpoint - if not email: - emails_resp = await github.get("user/emails", token=token) - emails = emails_resp.json() - email = next( - (e["email"] for e in emails if e.get("primary") and e.get("verified")), - None, - ) - if not email: - raise HTTPException( - status_code=400, detail="GitHub account has no accessible email." - ) - - user = db.query(User).filter(User.email == email).first() - if not user: - # TODO: Use existing create user logic - user = User( - email=email, - name=name, - provider_id=provider_id, - auth_type=AuthType.GITHUB, - is_active=True, - created_at=datetime.utcnow(), - ) - db.add(user) - db.commit() - db.refresh(user) - - access_token = auth.create_access_token( - {"sub": str(user.id), "email": user.email} - ) - return JSONResponse({"access_token": access_token, "token_type": "bearer"}) - except OAuthError as e: - # Log the detailed exception for debugging purposes - import logging - logging.error("OAuthError occurred during GitHub authentication", exc_info=e) - # Return a generic error message to the client - return JSONResponse({"error": "An error occurred during authentication. Please try again later."}, status_code=400) + return await login_github_user(request, db) diff --git a/app/api/routes/user.py b/app/api/routes/user.py index 848308d..fa03de0 100644 --- a/app/api/routes/user.py +++ b/app/api/routes/user.py @@ -1,33 +1,34 @@ from fastapi import APIRouter, Depends -from fastapi.security import OAuth2PasswordRequestForm from sqlalchemy.orm import Session from app.config.database import get_db -from app.schemas.users import OTPVerifyRequest, Token, UserCreate, UserResponse -from app.services.user import (get_current_user, login_user, register_with_otp, - verify_otp_and_create_user) +from app.schemas.users import OTPVerifyRequest, UserCreate, UserResponse +from app.services.user import ( + get_current_user, + register_with_otp, + verify_otp_and_create_user, +) router = APIRouter() @router.post("/register", status_code=201) def register_user(user_in: UserCreate, db: Session = Depends(get_db)): + """Register a new user with OTP verification. + It initiates the OTP process and returns a response to the user. + The user must then verify the OTP to complete registration.""" return register_with_otp(db, user_in) @router.post("/verify-otp", response_model=UserResponse) def verify_otp(request: OTPVerifyRequest, db: Session = Depends(get_db)): + """Verify OTP and create user. + This endpoint is called after the user has received an OTP via email. + It checks the OTP and creates the user if the OTP is valid.""" user = verify_otp_and_create_user(db, request.email, request.otp_code) return user -@router.post("/login", response_model=Token) -def login( - form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db) -): - return login_user(db, form_data.username, form_data.password) - - @router.get("/me") def read_users_me(current_user=Depends(get_current_user)): if hasattr(current_user, "id"): diff --git a/app/config/github.py b/app/config/github.py new file mode 100644 index 0000000..e2c94ff --- /dev/null +++ b/app/config/github.py @@ -0,0 +1,25 @@ +from authlib.integrations.starlette_client import OAuth +from starlette.config import Config + +from app.settings import settings + +oauth = OAuth( + Config( + environ={ + "GITHUB_CLIENT_ID": settings.GITHUB_CLIENT_ID, + "GITHUB_CLIENT_SECRET": settings.GITHUB_CLIENT_SECRET, + } + ) +) + +github = oauth.register( + name="github", + client_id=settings.GITHUB_CLIENT_ID, + client_secret=settings.GITHUB_CLIENT_SECRET, + access_token_url="https://github.com/login/oauth/access_token", + access_token_params=None, + authorize_url="https://github.com/login/oauth/authorize", + authorize_params=None, + api_base_url="https://api.github.com/", + client_kwargs={"scope": "user:email"}, +) diff --git a/app/models/user.py b/app/models/user.py index a13f9b8..044f4d0 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -7,6 +7,7 @@ from app.config.database import Base +# TODO: removed as AuthType is now in app.schemas.users class AuthType(Enum): LOCAL = "local" GOOGLE = "google" diff --git a/app/services/auth.py b/app/services/auth.py new file mode 100644 index 0000000..f3930a7 --- /dev/null +++ b/app/services/auth.py @@ -0,0 +1,89 @@ +import logging + +from authlib.integrations.starlette_client import OAuthError +from fastapi import HTTPException +from fastapi.responses import JSONResponse + +from app.config.github import github +from app.models.user import User +from app.schemas.users import AuthType, UserCreate +from app.services.user import create_user +from app.utils.auth import create_access_token, verify_password + + +def authenticate_user(db, email: str, password: str): + """Authenticate user with email and password using hashed password.""" + user = db.query(User).filter(User.email == email).first() + if not user or not user.password: + return None + if not verify_password(password, user.password): + return None + return user + + +def login_user(db, email: str, password: str): + """Login user with email and password. Returns an access token if successful. Raises HTTPException if authentication fails.""" + user = authenticate_user(db, email, password) + if not user: + raise HTTPException(status_code=401, detail="Invalid credentials") + access_token = create_access_token({"sub": str(user.id), "email": user.email}) + return {"access_token": access_token, "token_type": "bearer"} + + +def create_github_user(profile, email: str, db): + """Create a new user from GitHub profile data.""" + name = profile.get("name") or profile.get("login") + provider_id = str(profile.get("id")) + + user_in = UserCreate( + email=email, + name=name, + is_active=True, + provider_id=provider_id, + auth_type=AuthType.github, + ) + user = create_user(db, user_in) + return user + + +async def login_github_user(request, db): + """Login user via GitHub OAuth. Returns an access token if successful. Raises HTTPException if authentication fails.""" + try: + token = await github.authorize_access_token(request) + resp = await github.get("user", token=token) + profile = resp.json() + email = profile.get("email") + + # If email is not public, fetch from emails endpoint + if not email: + emails_resp = await github.get("user/emails", token=token) + emails = emails_resp.json() + email = next( + ( + e.get("email") + for e in emails + if e.get("primary") and e.get("verified") + ), + None, + ) + if not email: + raise HTTPException( + status_code=400, detail="GitHub account has no accessible email." + ) + + user = db.query(User).filter(User.email == email).first() + + # If user does not exist, create a new one + if not user: + user = create_github_user(profile, email, db) + + access_token = create_access_token({"sub": str(user.id), "email": user.email}) + return JSONResponse({"access_token": access_token, "token_type": "bearer"}) + except OAuthError as e: + logging.error("OAuthError occurred during GitHub authentication", exc_info=e) + return JSONResponse( + { + "error": "An error occurred during authentication. Please try again later." + }, + status_code=400, + ) diff --git a/app/services/user.py b/app/services/user.py index 30b6348..f0599f1 100644 --- a/app/services/user.py +++ b/app/services/user.py @@ -1,5 +1,3 @@ -import random -import re from datetime import datetime, timedelta from fastapi import Depends, HTTPException, status @@ -7,21 +5,25 @@ from jose import JWTError, jwt from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session -from sqlalchemy.orm import Session as _Session from app.config.database import get_db from app.models.otp import OTP from app.models.user import AuthType, User from app.schemas.users import UserCreate from app.settings import settings -from app.utils.auth import create_access_token, hash_password, verify_password +from app.utils.auth import ( + cleanup_expired_otps, + generate_otp, + hash_password, + validate_strong_password, +) from app.utils.email import send_otp_email security = HTTPBearer() def create_user(db: Session, user_in: UserCreate) -> User: - # Check if user already exists + """Create a new user in the database.""" existing = db.query(User).filter(User.email == user_in.email).first() if existing: raise HTTPException(status_code=400, detail="Email already registered") @@ -40,34 +42,17 @@ def create_user(db: Session, user_in: UserCreate) -> User: try: db.commit() db.refresh(user) - except IntegrityError: + except IntegrityError as exc: db.rollback() raise HTTPException( status_code=400, detail="Could not create user (integrity error)" - ) + ) from exc return user -def authenticate_user(db: Session, email: str, password: str): - user = db.query(User).filter(User.email == email).first() - if not user or not user.password: - return None - if not verify_password(password, user.password): - return None - return user - - -def login_user(db: Session, email: str, password: str): - user = authenticate_user(db, email, password) - if not user: - raise HTTPException(status_code=401, detail="Invalid credentials") - access_token = create_access_token({"sub": str(user.id), "email": user.email}) - return {"access_token": access_token, "token_type": "bearer"} - - def get_current_user( credentials: HTTPAuthorizationCredentials = Depends(security), - db: _Session = Depends(get_db), + db: Session = Depends(get_db), ): token = credentials.credentials credentials_exception = HTTPException( @@ -87,54 +72,24 @@ def get_current_user( if user is None: raise credentials_exception return user - except JWTError: - raise JWTError("JWT validation failed") - - -# TODO: move this function to a utils module or similar -def generate_otp(): - return str(random.randint(100000, 999999)) - - -# TODO: move this function to a utils module or similar -def validate_strong_password(password: str): - # At least 8 chars, one uppercase, one lowercase, one digit, one special char - if not password or len(password) < 8: - raise HTTPException( - status_code=400, detail="Password must be at least 8 characters long." - ) - if not re.search(r"[A-Z]", password): - raise HTTPException( - status_code=400, - detail="Password must contain at least one uppercase letter.", - ) - if not re.search(r"[a-z]", password): - raise HTTPException( - status_code=400, - detail="Password must contain at least one lowercase letter.", - ) - if not re.search(r"\d", password): - raise HTTPException( - status_code=400, detail="Password must contain at least one digit." - ) - if not re.search(r"[!@#$%^&*(),.?\":{}|<>]", password): - raise HTTPException( - status_code=400, - detail="Password must contain at least one special character.", - ) + except JWTError as exc: + raise JWTError("JWT validation failed") from exc def register_with_otp(db: Session, user_in: UserCreate): - # Check if user already exists + """Register a new user, initiate OTP process, and send OTP to email.""" existing = db.query(User).filter(User.email == user_in.email).first() if existing: raise HTTPException(status_code=400, detail="Email already registered") if not user_in.name: raise HTTPException(status_code=400, detail="Name is required.") + validate_strong_password(user_in.password) otp_code = generate_otp() + expires_at = datetime.utcnow() + timedelta(minutes=5) hashed_password = hash_password(user_in.password) if user_in.password else None + otp_entry = OTP( email=user_in.email, otp_code=otp_code, @@ -142,6 +97,7 @@ def register_with_otp(db: Session, user_in: UserCreate): password=hashed_password, is_active=1 if getattr(user_in, "is_active", True) else 0, provider_id=getattr(user_in, "provider_id", None), + # TODO: update this to use only local auth type auth_type=user_in.auth_type.value if hasattr(user_in, "auth_type") else "local", created_at=datetime.utcnow(), expires_at=expires_at, @@ -159,32 +115,30 @@ def verify_otp_and_create_user(db: Session, email: str, otp_code: str): ) if not otp_entry or otp_entry.is_expired(): raise HTTPException(status_code=400, detail="Invalid or expired OTP") - # Check if user already exists + existing = db.query(User).filter(User.email == email).first() if existing: db.delete(otp_entry) db.commit() raise HTTPException(status_code=400, detail="User already registered.") - # Create user from OTP details - # TODO: use existing user creation logic - user = User( + + # Use the existing create_user function with OTP data, in a transaction + user_in = UserCreate( email=otp_entry.email, name=otp_entry.name, - password=otp_entry.password, + password=otp_entry.password, # Already hashed is_active=bool(otp_entry.is_active), provider_id=otp_entry.provider_id, - auth_type=AuthType(otp_entry.auth_type), - created_at=datetime.utcnow(), + auth_type=otp_entry.auth_type, ) - db.add(user) - db.delete(otp_entry) - db.commit() - db.refresh(user) + try: + user = create_user(db, user_in) + db.delete(otp_entry) + db.commit() + except Exception as exc: + db.rollback() + raise HTTPException( + status_code=500, detail="User creation failed. Please try again." + ) from exc cleanup_expired_otps(db) return user - - -def cleanup_expired_otps(db: Session): - threshold = datetime.utcnow() - timedelta(minutes=5) - db.query(OTP).filter(OTP.expires_at < threshold).delete() - db.commit() diff --git a/app/utils/auth.py b/app/utils/auth.py index d1abd89..6332d07 100644 --- a/app/utils/auth.py +++ b/app/utils/auth.py @@ -1,9 +1,14 @@ +import random +import re from datetime import datetime, timedelta from typing import Optional +from fastapi import HTTPException from jose import jwt from passlib.context import CryptContext +from sqlalchemy.orm import Session +from app.models.otp import OTP from app.settings import settings # Password hashing context @@ -30,3 +35,44 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): to_encode.update({"exp": expire}) encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) return encoded_jwt + + +def validate_strong_password(password: str): + """Validate that the password meets strong security requirements. + Raises HTTPException if the password is not strong enough. + At least 8 chars, one uppercase, one lowercase, one digit, one special char + """ + if not password or len(password) < 8: + raise HTTPException( + status_code=400, detail="Password must be at least 8 characters long." + ) + if not re.search(r"[A-Z]", password): + raise HTTPException( + status_code=400, + detail="Password must contain at least one uppercase letter.", + ) + if not re.search(r"[a-z]", password): + raise HTTPException( + status_code=400, + detail="Password must contain at least one lowercase letter.", + ) + if not re.search(r"\d", password): + raise HTTPException( + status_code=400, detail="Password must contain at least one digit." + ) + if not re.search(r"[!@#$%^&*(),.?\":{}|<>]", password): + raise HTTPException( + status_code=400, + detail="Password must contain at least one special character.", + ) + + +def generate_otp(): + return str(random.randint(100000, 999999)) + + +def cleanup_expired_otps(db: Session): + """Remove expired OTP entries from the database.""" + threshold = datetime.utcnow() - timedelta(minutes=5) + db.query(OTP).filter(OTP.expires_at < threshold).delete() + db.commit() From 61320859c9197bd2f3878bc7c388c9011fff389c Mon Sep 17 00:00:00 2001 From: KevinRawal Date: Sun, 27 Jul 2025 13:33:12 +0530 Subject: [PATCH 7/7] fixed linting error --- app/api/routes/user.py | 7 ++----- app/services/user.py | 8 ++------ 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/app/api/routes/user.py b/app/api/routes/user.py index fa03de0..94ef567 100644 --- a/app/api/routes/user.py +++ b/app/api/routes/user.py @@ -3,11 +3,8 @@ from app.config.database import get_db from app.schemas.users import OTPVerifyRequest, UserCreate, UserResponse -from app.services.user import ( - get_current_user, - register_with_otp, - verify_otp_and_create_user, -) +from app.services.user import (get_current_user, register_with_otp, + verify_otp_and_create_user) router = APIRouter() diff --git a/app/services/user.py b/app/services/user.py index f0599f1..d36d0bc 100644 --- a/app/services/user.py +++ b/app/services/user.py @@ -11,12 +11,8 @@ from app.models.user import AuthType, User from app.schemas.users import UserCreate from app.settings import settings -from app.utils.auth import ( - cleanup_expired_otps, - generate_otp, - hash_password, - validate_strong_password, -) +from app.utils.auth import (cleanup_expired_otps, generate_otp, hash_password, + validate_strong_password) from app.utils.email import send_otp_email security = HTTPBearer()