From 693ac4cfd6a94959db65afde0893d939a17169d6 Mon Sep 17 00:00:00 2001 From: Christian Date: Mon, 9 Feb 2026 15:30:07 +0100 Subject: [PATCH] feat: Add case types to settings if not found feat: Update frontend navigation and links for support and CRM sections fix: Modify subscription listing and stats endpoints to support 'all' status feat: Implement subscription status filter in the subscriptions list view feat: Redirect ticket routes to the new sag path feat: Integrate devportal routes into the main application feat: Create a wizard for location creation with nested floors and rooms feat: Add product suppliers table to track multiple suppliers per product feat: Implement product audit log to track changes in products feat: Extend location types to include kantine and moedelokale feat: Add last_2fa_at column to users table for 2FA grace period tracking --- app/auth/backend/router.py | 23 +- app/core/auth_dependencies.py | 3 + app/core/auth_service.py | 28 +- app/core/config.py | 6 + app/customers/frontend/customer_detail.html | 921 +++++++++++++++++++- app/modules/hardware/templates/detail.html | 4 +- app/modules/locations/backend/router.py | 315 ++++++- app/modules/locations/frontend/views.py | 74 ++ app/modules/locations/models/schemas.py | 53 +- app/modules/locations/templates/create.html | 2 +- app/modules/locations/templates/detail.html | 6 +- app/modules/locations/templates/edit.html | 2 +- app/modules/locations/templates/list.html | 9 +- app/modules/locations/templates/map.html | 2 +- app/modules/locations/templates/wizard.html | 393 +++++++++ app/modules/nextcloud/backend/router.py | 46 + app/modules/nextcloud/backend/service.py | 61 ++ app/modules/sag/backend/router.py | 4 + app/modules/sag/frontend/views.py | 7 +- app/products/backend/router.py | 311 ++++++- app/products/frontend/detail.html | 348 ++++++++ app/settings/backend/router.py | 23 +- app/shared/frontend/base.html | 25 +- app/subscriptions/backend/router.py | 28 +- app/subscriptions/frontend/list.html | 36 +- app/ticket/frontend/views.py | 10 + main.py | 4 + migrations/110_product_suppliers.sql | 30 + migrations/111_product_audit_log.sql | 17 + migrations/112_locations_add_room_types.sql | 23 + migrations/113_auth_2fa_grace.sql | 11 + 31 files changed, 2703 insertions(+), 122 deletions(-) create mode 100644 app/modules/locations/templates/wizard.html create mode 100644 migrations/110_product_suppliers.sql create mode 100644 migrations/111_product_audit_log.sql create mode 100644 migrations/112_locations_add_room_types.sql create mode 100644 migrations/113_auth_2fa_grace.sql 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 +