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:
copilot-swe-agent[bot]
2025-12-09 17:30:12 +00:00
parent ddf287cde7
commit c8c0c762c5
15 changed files with 716 additions and 9 deletions

View 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

View File

@@ -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"}