all working

This commit is contained in:
HotSwapp
2025-08-10 21:34:11 -05:00
parent 14ee479edc
commit 1512b2d12a
22 changed files with 1453 additions and 489 deletions

View File

@@ -10,18 +10,23 @@ from sqlalchemy.orm import Session
from app.database.base import get_db
from app.models.user import User
from app.auth.security import (
authenticate_user,
create_access_token,
authenticate_user,
create_access_token,
create_refresh_token,
decode_refresh_token,
is_refresh_token_revoked,
revoke_refresh_token,
get_password_hash,
get_current_user,
get_admin_user
get_admin_user,
)
from app.auth.schemas import (
Token,
UserCreate,
UserResponse,
Token,
UserCreate,
UserResponse,
LoginRequest,
ThemePreferenceUpdate
ThemePreferenceUpdate,
RefreshRequest,
)
from app.config import settings
from app.core.logging import get_logger, log_auth_attempt
@@ -71,6 +76,12 @@ async def login(login_data: LoginRequest, request: Request, db: Session = Depend
access_token = create_access_token(
data={"sub": user.username}, expires_delta=access_token_expires
)
refresh_token = create_refresh_token(
user=user,
user_agent=request.headers.get("user-agent", ""),
ip_address=request.client.host if request.client else None,
db=db,
)
log_auth_attempt(
username=login_data.username,
@@ -85,7 +96,7 @@ async def login(login_data: LoginRequest, request: Request, db: Session = Depend
client_ip=client_ip
)
return {"access_token": access_token, "token_type": "bearer"}
return {"access_token": access_token, "token_type": "bearer", "refresh_token": refresh_token}
@router.post("/register", response_model=UserResponse)
@@ -130,22 +141,76 @@ async def read_users_me(current_user: User = Depends(get_current_user)):
@router.post("/refresh", response_model=Token)
async def refresh_token(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
async def refresh_token_endpoint(
request: Request,
db: Session = Depends(get_db),
body: RefreshRequest | None = None,
):
"""Refresh access token for current user"""
# Update last login timestamp
current_user.last_login = datetime.utcnow()
"""Issue a new access token using a valid, non-revoked refresh token.
For backwards compatibility with existing clients that may call this without a body,
consider falling back to Authorization header in the future if needed.
"""
# New flow: refresh token in body
if body and body.refresh_token:
payload = decode_refresh_token(body.refresh_token)
if not payload:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token")
jti = payload.get("jti")
username = payload.get("sub")
if not jti or not username:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token payload")
# Verify token not revoked/expired
if is_refresh_token_revoked(jti, db):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Refresh token revoked or expired")
# Load user
user = db.query(User).filter(User.username == username).first()
if not user or not user.is_active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found or inactive")
# Rotate refresh token on use
revoke_refresh_token(jti, db)
new_refresh_token = create_refresh_token(
user=user,
user_agent=request.headers.get("user-agent", ""),
ip_address=request.client.host if request.client else None,
db=db,
)
# Issue new access token
access_token_expires = timedelta(minutes=settings.access_token_expire_minutes)
access_token = create_access_token(
data={"sub": user.username}, expires_delta=access_token_expires
)
return {"access_token": access_token, "token_type": "bearer", "refresh_token": new_refresh_token}
# Legacy flow: Authorization header-based refresh
auth_header = request.headers.get("authorization") or request.headers.get("Authorization")
if not auth_header or not auth_header.lower().startswith("bearer "):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing credentials")
token = auth_header.split(" ", 1)[1].strip()
from app.auth.security import verify_token # local import to avoid circular
username = verify_token(token)
if not username:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
user = db.query(User).filter(User.username == username).first()
if not user or not user.is_active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found or inactive")
user.last_login = datetime.utcnow()
db.commit()
# Create new token with full expiration time
access_token_expires = timedelta(minutes=settings.access_token_expire_minutes)
access_token = create_access_token(
data={"sub": current_user.username},
expires_delta=access_token_expires
data={"sub": user.username}, expires_delta=access_token_expires
)
return {"access_token": access_token, "token_type": "bearer"}
@@ -159,6 +224,23 @@ async def list_users(
return users
@router.post("/logout")
async def logout(body: RefreshRequest | None = None, db: Session = Depends(get_db)):
"""Revoke the provided refresh token. Idempotent and safe to call multiple times.
The client should send a JSON body: { "refresh_token": "..." }.
"""
try:
if body and body.refresh_token:
payload = decode_refresh_token(body.refresh_token)
if payload and payload.get("jti"):
revoke_refresh_token(payload["jti"], db)
except Exception:
# Don't leak details; logout should be best-effort
pass
return {"status": "ok"}
@router.post("/theme-preference")
async def update_theme_preference(
theme_data: ThemePreferenceUpdate,

View File

@@ -2,7 +2,7 @@
Document Management API endpoints - QDROs, Templates, and General Documents
"""
from typing import List, Optional, Dict, Any
from fastapi import APIRouter, Depends, HTTPException, status, Query, UploadFile, File, Form
from fastapi import APIRouter, Depends, HTTPException, status, Query, UploadFile, File, Form, Request
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import or_, func, and_, desc, asc, text
from datetime import date, datetime
@@ -18,6 +18,8 @@ from app.models.lookups import FormIndex, FormList, Footer, Employee
from app.models.user import User
from app.auth.security import get_current_user
from app.models.additional import Document
from app.core.logging import get_logger
from app.services.audit import audit_service
router = APIRouter()
@@ -666,6 +668,78 @@ def _merge_template_variables(content: str, variables: Dict[str, Any]) -> str:
return merged
# --- Client Error Logging (for Documents page) ---
class ClientErrorLog(BaseModel):
"""Payload for client-side error logging"""
message: str
action: Optional[str] = None
stack: Optional[str] = None
url: Optional[str] = None
line: Optional[int] = None
column: Optional[int] = None
user_agent: Optional[str] = None
extra: Optional[Dict[str, Any]] = None
@router.post("/client-error")
async def log_client_error(
payload: ClientErrorLog,
request: Request,
db: Session = Depends(get_db),
current_user: Optional[User] = Depends(lambda: None)
):
"""Accept client-side error logs from the Documents page.
This endpoint is lightweight and safe to call; it records the error to the
application logs and best-effort to the audit log without interrupting the UI.
"""
logger = get_logger("client.documents")
client_ip = request.headers.get("x-forwarded-for")
if client_ip:
client_ip = client_ip.split(",")[0].strip()
else:
client_ip = request.client.host if request.client else None
logger.error(
"Client error reported",
action=payload.action,
message=payload.message,
stack=payload.stack,
page="/documents",
url=payload.url or str(request.url),
line=payload.line,
column=payload.column,
user=getattr(current_user, "username", None),
user_id=getattr(current_user, "id", None),
user_agent=payload.user_agent or request.headers.get("user-agent"),
client_ip=client_ip,
extra=payload.extra,
)
# Best-effort audit log; do not raise on failure
try:
audit_service.log_action(
db=db,
action="CLIENT_ERROR",
resource_type="DOCUMENTS",
user=current_user,
resource_id=None,
details={
"action": payload.action,
"message": payload.message,
"url": payload.url or str(request.url),
"line": payload.line,
"column": payload.column,
"extra": payload.extra,
},
request=request,
)
except Exception:
pass
return {"status": "logged"}
@router.post("/upload/{file_no}")
async def upload_document(
file_no: str,