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

320 lines
11 KiB
Python

from typing import Optional
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.models.UserModel import User
from app.models.AuditLogModel import AuditLog
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"
def record_failed_login_audit(db: Session, request: Request, email: str, user: Optional[User] = None, reason: str = "Invalid credentials"):
try:
req_id = getattr(request.state, "trace_id", None) or str(ulid.ULID())
audit = AuditLog(
audit_id=str(ulid.ULID()),
request_id=req_id,
user_id=user.user_id if user else None,
entity_type="auth",
entity_id=email,
action="failed_login",
old_value=None,
new_value={"reason": reason, "email": email},
ip_address=get_client_ip(request),
user_agent=(request.headers.get("user-agent") or "")[:255],
)
db.add(audit)
db.commit()
except Exception:
db.rollback()
@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:
record_failed_login_audit(db, request, data.email, None, "User not found")
raise HTTPException(status_code=401, detail="Invalid credentials")
if user.is_locked and user.deleted_at is None:
record_failed_login_audit(db, request, data.email, user, "Account locked")
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
is_locked_now = user.failed_login_attempts >= settings.MAX_FAILED_LOGIN
if is_locked_now:
user.is_locked = True
db.commit()
record_failed_login_audit(
db,
request,
data.email,
user,
"Account locked due to too many failed attempts" if is_locked_now else "Invalid credentials"
)
if is_locked_now:
raise HTTPException(status_code=403, detail="Account locked due to too many failed attempts.")
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(
request: Request,
response: Response,
refresh_token: Optional[str] = Cookie(None),
db: Session = Depends(get_db)
):
if not refresh_token:
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "):
refresh_token = auth_header.split(" ")[1]
if not refresh_token:
refresh_token = request.headers.get("X-Refresh-Token")
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:
record_failed_login_audit(db, request, form_data.username, None, "User not found")
raise HTTPException(status_code=401, detail="Invalid credentials")
if user.is_locked and user.deleted_at is None:
record_failed_login_audit(db, request, form_data.username, user, "Account locked")
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
is_locked_now = user.failed_login_attempts >= settings.MAX_FAILED_LOGIN
if is_locked_now:
user.is_locked = True
db.commit()
record_failed_login_audit(
db,
request,
form_data.username,
user,
"Account locked due to too many failed attempts" if is_locked_now else "Invalid credentials"
)
if is_locked_now:
raise HTTPException(status_code=403, detail="Account locked due to too many failed attempts.")
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"
}