diff --git a/app/auth/backend/router.py b/app/auth/backend/router.py index 8e8aa74..3a0041e 100644 --- a/app/auth/backend/router.py +++ b/app/auth/backend/router.py @@ -5,6 +5,7 @@ from fastapi import APIRouter, HTTPException, status, Request, Depends, Response from pydantic import BaseModel from typing import Optional from app.core.auth_service import AuthService +from app.core.config import settings from app.core.auth_dependencies import get_current_user import logging @@ -26,7 +27,7 @@ class LoginResponse(BaseModel): class LogoutRequest(BaseModel): - token_jti: str + token_jti: Optional[str] = None class TwoFactorCodeRequest(BaseModel): @@ -74,8 +75,8 @@ async def login(request: Request, credentials: LoginRequest, response: Response) key="access_token", value=access_token, httponly=True, - samesite="lax", - secure=False + samesite=settings.COOKIE_SAMESITE, + secure=settings.COOKIE_SECURE ) return LoginResponse( @@ -86,18 +87,20 @@ async def login(request: Request, credentials: LoginRequest, response: Response) @router.post("/logout") async def logout( - request: LogoutRequest, response: Response, - current_user: dict = Depends(get_current_user) + current_user: dict = Depends(get_current_user), + request: Optional[LogoutRequest] = None ): """ Revoke JWT token (logout) """ - AuthService.revoke_token( - request.token_jti, - current_user['id'], - current_user.get('is_shadow_admin', False) - ) + token_jti = request.token_jti if request and request.token_jti else current_user.get("token_jti") + if token_jti: + AuthService.revoke_token( + token_jti, + current_user['id'], + current_user.get('is_shadow_admin', False) + ) response.delete_cookie("access_token") diff --git a/app/core/auth_dependencies.py b/app/core/auth_dependencies.py index 53f57ff..15d3b8e 100644 --- a/app/core/auth_dependencies.py +++ b/app/core/auth_dependencies.py @@ -50,6 +50,7 @@ async def get_current_user( username = payload.get("username") is_superadmin = payload.get("is_superadmin", False) is_shadow_admin = payload.get("shadow_admin", False) + token_jti = payload.get("jti") # Add IP address to user info ip_address = request.client.host if request.client else None @@ -64,6 +65,7 @@ async def get_current_user( "is_shadow_admin": True, "is_2fa_enabled": True, "ip_address": ip_address, + "token_jti": token_jti, "permissions": AuthService.get_all_permissions() } @@ -81,6 +83,7 @@ async def get_current_user( "is_shadow_admin": False, "is_2fa_enabled": user_details.get('is_2fa_enabled') if user_details else False, "ip_address": ip_address, + "token_jti": token_jti, "permissions": AuthService.get_user_permissions(user_id) } diff --git a/app/core/auth_service.py b/app/core/auth_service.py index 28cfc2c..c08f66a 100644 --- a/app/core/auth_service.py +++ b/app/core/auth_service.py @@ -16,7 +16,7 @@ import logging logger = logging.getLogger(__name__) # JWT Settings -SECRET_KEY = getattr(settings, 'JWT_SECRET_KEY', 'your-secret-key-change-in-production') +SECRET_KEY = settings.JWT_SECRET_KEY ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 8 # 8 timer @@ -267,7 +267,7 @@ class AuthService: user = execute_query_single( """SELECT user_id, username, email, password_hash, full_name, is_active, is_superadmin, failed_login_attempts, locked_until, - is_2fa_enabled, totp_secret + is_2fa_enabled, totp_secret, last_2fa_at FROM users WHERE username = %s OR email = %s""", (username, username)) @@ -322,17 +322,29 @@ class AuthService: logger.warning(f"❌ Login failed: Invalid password - {username} (attempt {failed_attempts})") return None, "Invalid username or password" - # 2FA check + # 2FA check (only once per grace window) if user.get('is_2fa_enabled'): if not user.get('totp_secret'): return None, "2FA not configured" - if not otp_code: - return None, "2FA code required" + last_2fa_at = user.get("last_2fa_at") + grace_hours = max(1, int(settings.TWO_FA_GRACE_HOURS)) + grace_window = timedelta(hours=grace_hours) + now = datetime.utcnow() + within_grace = bool(last_2fa_at and (now - last_2fa_at) < grace_window) - if not AuthService.verify_totp_code(user['totp_secret'], otp_code): - logger.warning(f"❌ Login failed: Invalid 2FA - {username}") - return None, "Invalid 2FA code" + if not within_grace: + if not otp_code: + return None, "2FA code required" + + if not AuthService.verify_totp_code(user['totp_secret'], otp_code): + logger.warning(f"❌ Login failed: Invalid 2FA - {username}") + return None, "Invalid 2FA code" + + execute_update( + "UPDATE users SET last_2fa_at = CURRENT_TIMESTAMP WHERE user_id = %s", + (user['user_id'],) + ) # Success! Reset failed attempts and update last login execute_update( diff --git a/app/core/config.py b/app/core/config.py index fb9eef1..2fa4171 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -33,6 +33,9 @@ class Settings(BaseSettings): # Security SECRET_KEY: str = "dev-secret-key-change-in-production" + JWT_SECRET_KEY: str = "dev-jwt-secret-key-change-in-production" + COOKIE_SECURE: bool = False + COOKIE_SAMESITE: str = "lax" ALLOWED_ORIGINS: List[str] = ["http://localhost:8000", "http://localhost:3000"] CORS_ORIGINS: str = "http://localhost:8000,http://localhost:3000" @@ -43,6 +46,9 @@ class Settings(BaseSettings): SHADOW_ADMIN_TOTP_SECRET: str = "" SHADOW_ADMIN_EMAIL: str = "shadowadmin@bmcnetworks.dk" SHADOW_ADMIN_FULL_NAME: str = "Shadow Administrator" + + # 2FA grace period (hours) before re-prompting + TWO_FA_GRACE_HOURS: int = 24 # Logging LOG_LEVEL: str = "INFO" diff --git a/app/customers/frontend/customer_detail.html b/app/customers/frontend/customer_detail.html index 3c84165..60d31c8 100644 --- a/app/customers/frontend/customer_detail.html +++ b/app/customers/frontend/customer_detail.html @@ -311,6 +311,11 @@ Abonnements Matrix +