320 lines
11 KiB
Python
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"
|
|
}
|
|
|