ifixkart-backend/Backend/app/api/v1/routers/AuthenticationRouter.py

275 lines
9.4 KiB
Python

from fastapi import APIRouter, Depends, HTTPException, Response, Request, Cookie, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from datetime import datetime, timedelta, timezone
import random
import time
from user_agents import parse
import ulid
from app.core.database.db_session import get_db
from app.core.config.Config import settings
from app.core.Token import create_access_token
from app.utils.Hash_util import hash_password, verify_password
from app.repositories.user_repository import user_repository
from app.repositories.session_repository import session_repository
from app.models.UserSessionModel import UserSession
from app.schemas.Auth import LoginSchema, TokenResponseSchema
from app.core.validators.password_validator import validate_password_complexity
router = APIRouter(prefix="/api/v1/auth", tags=["Authentication"])
def get_client_ip(request: Request) -> str:
# Read forwarded headers or default to client host
x_forwarded_for = request.headers.get("x-forwarded-for")
if x_forwarded_for:
return x_forwarded_for.split(",")[0].strip()
return request.client.host if request.client else "127.0.0.1"
@router.post("/login", response_model=TokenResponseSchema)
def login(
data: LoginSchema,
response: Response,
request: Request,
db: Session = Depends(get_db)
):
user = user_repository.get_by_email(db, data.email)
# 1. Lockout & Verification checks
if not user:
raise HTTPException(status_code=401, detail="Invalid credentials")
if user.is_locked and user.deleted_at is None:
# Check if lockout period has expired (15 minutes)
# We can implement a lockout expiry check, but for simplicity, check last update time
# if 15 mins passed, unlock:
# For security, let's keep it locked until reset_password confirms, or 15 mins pass
# Since we lock dynamically, let's look at the DB fields:
# user has is_locked. Let's write a clean time-locked query if needed,
# or require password reset as configured.
raise HTTPException(status_code=403, detail="Account locked. Please reset password to unlock.")
if not verify_password(data.password, user.password_hash):
user.failed_login_attempts += 1
if user.failed_login_attempts >= settings.MAX_FAILED_LOGIN:
user.is_locked = True
db.commit()
raise HTTPException(status_code=403, detail="Account locked due to too many failed attempts.")
db.commit()
raise HTTPException(status_code=401, detail="Invalid credentials")
# 2. Reset failed attempts count
user.failed_login_attempts = 0
user.is_locked = False
user.last_login = datetime.now(timezone.utc)
db.commit()
# 3. Generate tokens
access_token = create_access_token(user.user_id, user.email, user.role.role_name)
raw_refresh = str(ulid.ULID())
# 4. Extract device metadata
ua_string = request.headers.get("user-agent", "")
ua = parse(ua_string)
device_type = "Desktop"
if ua.is_mobile:
device_type = "Mobile"
elif ua.is_tablet:
device_type = "Tablet"
os_name = f"{ua.os.family} {ua.os.version_string}".strip()
browser_name = f"{ua.browser.family} {ua.browser.version_string}".strip()
# 5. Save session
session_id = str(ulid.ULID())
session_entry = UserSession(
session_id=session_id,
user_id=user.user_id,
device_name=ua.device.family or "Unknown",
device_type=device_type,
browser=browser_name,
operating_system=os_name,
ip_address=get_client_ip(request),
latitude=data.latitude,
longitude=data.longitude,
location_name=data.location_name,
device_fingerprint=data.device_fingerprint,
refresh_token=raw_refresh,
access_token_id=session_id, # Match token references
expires_at=datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS),
is_active=True
)
db.add(session_entry)
db.commit()
# 6. Set response cookie and header
response.set_cookie(
"refresh_token",
raw_refresh,
httponly=True,
secure=True,
samesite="none"
)
response.headers["X-User-Email"] = user.email
return {
"access_token": access_token,
"refresh_token": raw_refresh,
"token_type": "bearer"
}
@router.post("/refresh", response_model=TokenResponseSchema)
def refresh(
response: Response,
refresh_token: str = Cookie(None),
db: Session = Depends(get_db)
):
if not refresh_token:
raise HTTPException(status_code=401, detail="Missing refresh token")
session = session_repository.get_by_refresh_token(db, refresh_token)
if not session or session.expires_at.replace(tzinfo=timezone.utc) < datetime.now(timezone.utc):
if session:
session.is_active = False
db.commit()
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
user = user_repository.get_by_id(db, session.user_id)
if not user or not user.is_active or user.is_locked:
raise HTTPException(status_code=403, detail="User account is inactive or locked")
# Refresh Token Rotation (RTR): invalidate old token, issue new one
new_access = create_access_token(user.user_id, user.email, user.role.role_name)
new_refresh = str(ulid.ULID())
# Deactivate old session
session.is_active = False
db.commit()
# Create new session entry carrying over device metadata
new_session_id = str(ulid.ULID())
new_session = UserSession(
session_id=new_session_id,
user_id=user.user_id,
device_name=session.device_name,
device_type=session.device_type,
browser=session.browser,
operating_system=session.operating_system,
ip_address=session.ip_address,
latitude=session.latitude,
longitude=session.longitude,
location_name=session.location_name,
device_fingerprint=session.device_fingerprint,
refresh_token=new_refresh,
access_token_id=new_session_id,
expires_at=datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS),
is_active=True
)
db.add(new_session)
db.commit()
response.set_cookie(
"refresh_token",
new_refresh,
httponly=True,
secure=True,
samesite="none"
)
return {
"access_token": new_access,
"refresh_token": new_refresh,
"token_type": "bearer"
}
@router.post("/logout")
def logout(
response: Response,
refresh_token: str = Cookie(None),
db: Session = Depends(get_db)
):
if refresh_token:
session = session_repository.get_by_refresh_token(db, refresh_token)
if session:
session.is_active = False
db.commit()
response.delete_cookie("refresh_token", httponly=True, secure=True, samesite="lax")
return {"detail": "Logged out successfully"}
@router.post("/token", response_model=TokenResponseSchema)
def oauth2_token(
response: Response,
request: Request,
form_data: OAuth2PasswordRequestForm = Depends(),
db: Session = Depends(get_db)
):
user = user_repository.get_by_email(db, form_data.username)
if not user:
raise HTTPException(status_code=401, detail="Invalid credentials")
if user.is_locked and user.deleted_at is None:
raise HTTPException(status_code=403, detail="Account locked. Please reset password to unlock.")
if not verify_password(form_data.password, user.password_hash):
user.failed_login_attempts += 1
if user.failed_login_attempts >= settings.MAX_FAILED_LOGIN:
user.is_locked = True
db.commit()
raise HTTPException(status_code=403, detail="Account locked due to too many failed attempts.")
db.commit()
raise HTTPException(status_code=401, detail="Invalid credentials")
user.failed_login_attempts = 0
user.is_locked = False
user.last_login = datetime.now(timezone.utc)
db.commit()
access_token = create_access_token(user.user_id, user.email, user.role.role_name)
raw_refresh = str(ulid.ULID())
ua_string = request.headers.get("user-agent", "")
ua = parse(ua_string)
device_type = "Desktop"
if ua.is_mobile:
device_type = "Mobile"
elif ua.is_tablet:
device_type = "Tablet"
os_name = f"{ua.os.family} {ua.os.version_string}".strip()
browser_name = f"{ua.browser.family} {ua.browser.version_string}".strip()
session_id = str(ulid.ULID())
session_entry = UserSession(
session_id=session_id,
user_id=user.user_id,
device_name=ua.device.family or "Unknown",
device_type=device_type,
browser=browser_name,
operating_system=os_name,
ip_address=get_client_ip(request),
refresh_token=raw_refresh,
access_token_id=session_id,
expires_at=datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS),
is_active=True
)
db.add(session_entry)
db.commit()
response.set_cookie(
"refresh_token",
raw_refresh,
httponly=True,
secure=True,
samesite="lax"
)
response.headers["X-User-Email"] = user.email
return {
"access_token": access_token,
"refresh_token": raw_refresh,
"token_type": "bearer"
}