mirror of
https://github.com/mblanke/ThreatHunt.git
synced 2026-03-01 14:00:20 -05:00
Implement Phase 2: Refresh tokens, 2FA, password reset, and audit logging
Co-authored-by: mblanke <9078342+mblanke@users.noreply.github.com>
This commit is contained in:
75
backend/app/api/routes/audit.py
Normal file
75
backend/app/api/routes/audit.py
Normal file
@@ -0,0 +1,75 @@
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_active_user, require_role
|
||||
from app.models.user import User
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.schemas.audit import AuditLogRead
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", response_model=List[AuditLogRead])
|
||||
async def list_audit_logs(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
action: Optional[str] = Query(None, description="Filter by action type"),
|
||||
resource_type: Optional[str] = Query(None, description="Filter by resource type"),
|
||||
start_date: Optional[datetime] = Query(None, description="Filter from date"),
|
||||
end_date: Optional[datetime] = Query(None, description="Filter to date"),
|
||||
current_user: User = Depends(require_role(["admin"])),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
List audit logs (admin only, scoped to tenant)
|
||||
|
||||
Provides a complete audit trail of actions within the tenant.
|
||||
"""
|
||||
# Base query scoped to tenant
|
||||
query = db.query(AuditLog).filter(AuditLog.tenant_id == current_user.tenant_id)
|
||||
|
||||
# Apply filters
|
||||
if action:
|
||||
query = query.filter(AuditLog.action == action)
|
||||
if resource_type:
|
||||
query = query.filter(AuditLog.resource_type == resource_type)
|
||||
if start_date:
|
||||
query = query.filter(AuditLog.created_at >= start_date)
|
||||
if end_date:
|
||||
query = query.filter(AuditLog.created_at <= end_date)
|
||||
|
||||
# Order by most recent first
|
||||
query = query.order_by(AuditLog.created_at.desc())
|
||||
|
||||
# Paginate
|
||||
logs = query.offset(skip).limit(limit).all()
|
||||
|
||||
return logs
|
||||
|
||||
|
||||
@router.get("/{log_id}", response_model=AuditLogRead)
|
||||
async def get_audit_log(
|
||||
log_id: int,
|
||||
current_user: User = Depends(require_role(["admin"])),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get a specific audit log entry (admin only)
|
||||
"""
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
log = db.query(AuditLog).filter(
|
||||
AuditLog.id == log_id,
|
||||
AuditLog.tenant_id == current_user.tenant_id
|
||||
).first()
|
||||
|
||||
if not log:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Audit log not found"
|
||||
)
|
||||
|
||||
return log
|
||||
@@ -1,13 +1,26 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import io
|
||||
import qrcode
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import verify_password, get_password_hash, create_access_token
|
||||
from app.core.security import (
|
||||
verify_password, get_password_hash, create_access_token,
|
||||
create_refresh_token, create_reset_token, generate_totp_secret,
|
||||
verify_totp, get_totp_uri
|
||||
)
|
||||
from app.core.deps import get_current_active_user
|
||||
from app.models.user import User
|
||||
from app.models.tenant import Tenant
|
||||
from app.schemas.auth import Token, UserLogin, UserRegister
|
||||
from app.models.refresh_token import RefreshToken
|
||||
from app.models.password_reset_token import PasswordResetToken
|
||||
from app.schemas.auth import (
|
||||
Token, UserLogin, UserRegister, RefreshTokenRequest,
|
||||
PasswordResetRequest, PasswordResetConfirm,
|
||||
TwoFactorSetup, TwoFactorVerify
|
||||
)
|
||||
from app.schemas.user import UserRead, UserUpdate
|
||||
|
||||
router = APIRouter()
|
||||
@@ -102,6 +115,17 @@ async def login(
|
||||
detail="Inactive user"
|
||||
)
|
||||
|
||||
# Check 2FA if enabled (TOTP code should be in scopes for OAuth2)
|
||||
if user.totp_enabled:
|
||||
# For OAuth2 password flow, we'll check totp in scopes
|
||||
totp_code = form_data.scopes[0] if form_data.scopes else None
|
||||
if not totp_code or not verify_totp(user.totp_secret, totp_code):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid 2FA code",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Create access token
|
||||
access_token = create_access_token(
|
||||
data={
|
||||
@@ -111,7 +135,21 @@ async def login(
|
||||
}
|
||||
)
|
||||
|
||||
return {"access_token": access_token, "token_type": "bearer"}
|
||||
# Create refresh token
|
||||
refresh_token_str = create_refresh_token()
|
||||
refresh_token_obj = RefreshToken(
|
||||
token=refresh_token_str,
|
||||
user_id=user.id,
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(days=30)
|
||||
)
|
||||
db.add(refresh_token_obj)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token_str,
|
||||
"token_type": "bearer"
|
||||
}
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserRead)
|
||||
@@ -162,3 +200,233 @@ async def update_current_user_profile(
|
||||
db.refresh(current_user)
|
||||
|
||||
return current_user
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=Token)
|
||||
async def refresh_access_token(
|
||||
refresh_request: RefreshTokenRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Refresh access token using refresh token
|
||||
|
||||
Provides a new access token without requiring login.
|
||||
"""
|
||||
# Find refresh token
|
||||
refresh_token = db.query(RefreshToken).filter(
|
||||
RefreshToken.token == refresh_request.refresh_token,
|
||||
RefreshToken.is_revoked == False
|
||||
).first()
|
||||
|
||||
if not refresh_token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid refresh token"
|
||||
)
|
||||
|
||||
# Check if expired
|
||||
if refresh_token.expires_at < datetime.now(timezone.utc):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Refresh token expired"
|
||||
)
|
||||
|
||||
# Get user
|
||||
user = db.query(User).filter(User.id == refresh_token.user_id).first()
|
||||
if not user or not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found or inactive"
|
||||
)
|
||||
|
||||
# Create new access token
|
||||
access_token = create_access_token(
|
||||
data={
|
||||
"sub": user.id,
|
||||
"tenant_id": user.tenant_id,
|
||||
"role": user.role
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_request.refresh_token,
|
||||
"token_type": "bearer"
|
||||
}
|
||||
|
||||
|
||||
@router.post("/2fa/setup", response_model=TwoFactorSetup)
|
||||
async def setup_two_factor(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Set up two-factor authentication
|
||||
|
||||
Generates a TOTP secret and QR code URI for the user.
|
||||
"""
|
||||
if current_user.totp_enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="2FA is already enabled"
|
||||
)
|
||||
|
||||
# Generate secret
|
||||
secret = generate_totp_secret()
|
||||
current_user.totp_secret = secret
|
||||
db.commit()
|
||||
|
||||
# Get QR code URI
|
||||
qr_uri = get_totp_uri(secret, current_user.username)
|
||||
|
||||
return {
|
||||
"secret": secret,
|
||||
"qr_code_uri": qr_uri
|
||||
}
|
||||
|
||||
|
||||
@router.post("/2fa/verify")
|
||||
async def verify_two_factor(
|
||||
verify_request: TwoFactorVerify,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Verify and enable two-factor authentication
|
||||
|
||||
User must provide a valid TOTP code to enable 2FA.
|
||||
"""
|
||||
if current_user.totp_enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="2FA is already enabled"
|
||||
)
|
||||
|
||||
if not current_user.totp_secret:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="2FA setup not initiated. Call /2fa/setup first."
|
||||
)
|
||||
|
||||
# Verify code
|
||||
if not verify_totp(current_user.totp_secret, verify_request.code):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid 2FA code"
|
||||
)
|
||||
|
||||
# Enable 2FA
|
||||
current_user.totp_enabled = True
|
||||
db.commit()
|
||||
|
||||
return {"message": "2FA enabled successfully"}
|
||||
|
||||
|
||||
@router.post("/2fa/disable")
|
||||
async def disable_two_factor(
|
||||
verify_request: TwoFactorVerify,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Disable two-factor authentication
|
||||
|
||||
User must provide a valid TOTP code to disable 2FA.
|
||||
"""
|
||||
if not current_user.totp_enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="2FA is not enabled"
|
||||
)
|
||||
|
||||
# Verify code
|
||||
if not verify_totp(current_user.totp_secret, verify_request.code):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid 2FA code"
|
||||
)
|
||||
|
||||
# Disable 2FA
|
||||
current_user.totp_enabled = False
|
||||
current_user.totp_secret = None
|
||||
db.commit()
|
||||
|
||||
return {"message": "2FA disabled successfully"}
|
||||
|
||||
|
||||
@router.post("/password-reset/request")
|
||||
async def request_password_reset(
|
||||
reset_request: PasswordResetRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Request a password reset
|
||||
|
||||
Sends a password reset email to the user (mock implementation).
|
||||
"""
|
||||
# Find user by email
|
||||
user = db.query(User).filter(User.email == reset_request.email).first()
|
||||
|
||||
# Don't reveal if email exists or not (security best practice)
|
||||
# Always return success even if email doesn't exist
|
||||
if user:
|
||||
# Create reset token
|
||||
reset_token = create_reset_token()
|
||||
reset_token_obj = PasswordResetToken(
|
||||
token=reset_token,
|
||||
user_id=user.id,
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(hours=1)
|
||||
)
|
||||
db.add(reset_token_obj)
|
||||
db.commit()
|
||||
|
||||
# TODO: Send email with reset link
|
||||
# For now, we'll just log it (in production, use an email service)
|
||||
print(f"Password reset token for {user.email}: {reset_token}")
|
||||
|
||||
return {"message": "If the email exists, a password reset link has been sent"}
|
||||
|
||||
|
||||
@router.post("/password-reset/confirm")
|
||||
async def confirm_password_reset(
|
||||
reset_confirm: PasswordResetConfirm,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Confirm password reset with token
|
||||
|
||||
Sets a new password for the user.
|
||||
"""
|
||||
# Find reset token
|
||||
reset_token = db.query(PasswordResetToken).filter(
|
||||
PasswordResetToken.token == reset_confirm.token,
|
||||
PasswordResetToken.is_used == False
|
||||
).first()
|
||||
|
||||
if not reset_token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid or expired reset token"
|
||||
)
|
||||
|
||||
# Check if expired
|
||||
if reset_token.expires_at < datetime.now(timezone.utc):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Reset token expired"
|
||||
)
|
||||
|
||||
# Get user
|
||||
user = db.query(User).filter(User.id == reset_token.user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
# Update password
|
||||
user.password_hash = get_password_hash(reset_confirm.new_password)
|
||||
reset_token.is_used = True
|
||||
db.commit()
|
||||
|
||||
return {"message": "Password reset successful"}
|
||||
|
||||
Reference in New Issue
Block a user