From 2b471ef09f2d41928b60e07ba4f3bbfb3941882d Mon Sep 17 00:00:00 2001 From: Matt Hills Date: Mon, 20 Oct 2025 20:03:46 -0400 Subject: [PATCH] Add optional no-auth mode for simplified local testing This update introduces an AUTH_MODE configuration that allows developers to easily test the application locally without requiring Entra ID setup. Changes: - Add AUTH_MODE environment variable (entra/none) to .env.example and config - Update auth decorators to support both authentication modes - Create role selection page for no-auth mode (admin/user choice) - Modify auth routes to conditionally use MSAL only in Entra ID mode - Update UserModel to support mock test users with string IDs - Add visual indicator in navbar when running in no-auth mode Benefits: - Zero Azure AD configuration needed for local development - Quick testing of role-based features (admin vs user) - Existing Entra ID functionality remains unchanged - Simple toggle via environment variable To use no-auth mode, set AUTH_MODE=none in .env file. For production, use AUTH_MODE=entra (default). --- .env.example | 4 ++ src/app/blueprints/auth/routes.py | 62 ++++++++++++++++- src/app/config.py | 4 ++ src/app/decorators/auth.py | 47 +++++++++++-- src/app/models/user.py | 26 +++++-- src/app/templates/layout.html | 9 +++ src/app/templates/select_role.html | 105 +++++++++++++++++++++++++++++ 7 files changed, 245 insertions(+), 12 deletions(-) create mode 100644 src/app/templates/select_role.html diff --git a/.env.example b/.env.example index a7aeed6..00cf69f 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,10 @@ SECRET_KEY=your-secret-key-change-this-in-production FLASK_DEBUG=1 +# Authentication Mode +# Options: 'entra' (Entra ID/Azure AD) or 'none' (no authentication for local testing) +AUTH_MODE=none + # Entra ID (Azure AD) Configuration # Get these values from your Azure Portal > App Registrations AZURE_CLIENT_ID=your-client-id-here diff --git a/src/app/blueprints/auth/routes.py b/src/app/blueprints/auth/routes.py index 08ab072..0374488 100644 --- a/src/app/blueprints/auth/routes.py +++ b/src/app/blueprints/auth/routes.py @@ -1,14 +1,20 @@ -"""Authentication routes for Entra ID login/logout""" +"""Authentication routes for Entra ID login/logout and no-auth mode""" import secrets from flask import session, redirect, url_for, request, current_app, render_template, flash from . import auth_bp -from app.services.msal_service import MSALService from app.models.user import UserModel @auth_bp.route('/login') def login(): - """Initiate Entra ID login flow""" + """Initiate Entra ID login flow or redirect to role selection in no-auth mode""" + # Check if we're in no-auth mode + if current_app.config.get('AUTH_MODE') == 'none': + return redirect(url_for('auth.select_role')) + + # Entra ID mode + from app.services.msal_service import MSALService + # Generate state token for CSRF protection state = secrets.token_urlsafe(16) session['auth_state'] = state @@ -23,9 +29,51 @@ def login(): return redirect(auth_url) +@auth_bp.route('/select-role', methods=['GET', 'POST']) +def select_role(): + """Role selection page for no-auth mode""" + # Only available in no-auth mode + if current_app.config.get('AUTH_MODE') != 'none': + flash('Role selection is only available in no-auth mode', 'error') + return redirect(url_for('auth.login')) + + if request.method == 'POST': + role = request.form.get('role', 'user') + + # Validate role + if role not in ['admin', 'user']: + role = 'user' + + # Create a mock user for testing + # Use a consistent ID based on role for session persistence + user_id = f"test-{role}-user" + + # Store user in session + session['user'] = { + 'id': user_id, + 'email': f'{role}@test.local', + 'name': f'Test {role.capitalize()}', + 'role': role + } + + current_app.logger.info(f"No-auth mode: User selected {role} role") + + # Redirect to the originally requested page or home + next_url = session.pop('next', None) + if next_url: + return redirect(next_url) + + return redirect(url_for('main.index')) + + return render_template('select_role.html') + + @auth_bp.route('/callback') def callback(): """Handle the callback from Entra ID after authentication""" + # Import MSAL service only when needed (Entra ID mode) + from app.services.msal_service import MSALService + # Verify state to prevent CSRF state = request.args.get('state') if state != session.get('auth_state'): @@ -104,6 +152,14 @@ def logout(): # Clear session session.clear() + # Check if we're in no-auth mode + if current_app.config.get('AUTH_MODE') == 'none': + # Just redirect to home in no-auth mode + return redirect(url_for('main.index')) + + # Entra ID mode - logout from Azure AD + from app.services.msal_service import MSALService + # Remove from MSAL cache MSALService.remove_account() diff --git a/src/app/config.py b/src/app/config.py index 64b2c8b..01387f0 100644 --- a/src/app/config.py +++ b/src/app/config.py @@ -28,6 +28,10 @@ class Config: SESSION_USE_SIGNER = True SESSION_FILE_DIR = os.path.join(DATA_FOLDER, 'flask_session') + # Authentication Mode + # Options: 'entra' (Entra ID/Azure AD) or 'none' (no authentication) + AUTH_MODE = os.environ.get('AUTH_MODE', 'entra').lower() + # Entra ID (Azure AD) Configuration AZURE_CLIENT_ID = os.environ.get('AZURE_CLIENT_ID') AZURE_CLIENT_SECRET = os.environ.get('AZURE_CLIENT_SECRET') diff --git a/src/app/decorators/auth.py b/src/app/decorators/auth.py index 5c3b27e..8b662f5 100644 --- a/src/app/decorators/auth.py +++ b/src/app/decorators/auth.py @@ -4,17 +4,36 @@ from flask import session, redirect, url_for, request, abort, current_app from app.models.user import UserModel +def _ensure_no_auth_user(): + """ + Helper function to ensure a user exists in session when AUTH_MODE is 'none'. + If no user in session, redirect to role selection page. + """ + if current_app.config.get('AUTH_MODE') == 'none' and 'user' not in session: + # Redirect to role selection page + session['next'] = request.url + return redirect(url_for('auth.select_role')) + return None + + def login_required(f): """ Decorator to require user authentication. Redirects to login if user is not authenticated. + In 'none' auth mode, redirects to role selection if no session exists. """ @wraps(f) def decorated_function(*args, **kwargs): - if 'user' not in session: - # Store the requested URL to redirect back after login + # Check if we're in no-auth mode and need to set up a user + if current_app.config.get('AUTH_MODE') == 'none': + redirect_response = _ensure_no_auth_user() + if redirect_response: + return redirect_response + elif 'user' not in session: + # Entra ID mode - redirect to login session['next'] = request.url return redirect(url_for('auth.login')) + return f(*args, **kwargs) return decorated_function @@ -26,7 +45,13 @@ def admin_required(f): """ @wraps(f) def decorated_function(*args, **kwargs): - if 'user' not in session: + # Check if we're in no-auth mode and need to set up a user + if current_app.config.get('AUTH_MODE') == 'none': + redirect_response = _ensure_no_auth_user() + if redirect_response: + return redirect_response + elif 'user' not in session: + # Entra ID mode - redirect to login session['next'] = request.url return redirect(url_for('auth.login')) @@ -47,7 +72,13 @@ def tenant_access_required(f): """ @wraps(f) def decorated_function(*args, **kwargs): - if 'user' not in session: + # Check if we're in no-auth mode and need to set up a user + if current_app.config.get('AUTH_MODE') == 'none': + redirect_response = _ensure_no_auth_user() + if redirect_response: + return redirect_response + elif 'user' not in session: + # Entra ID mode - redirect to login session['next'] = request.url return redirect(url_for('auth.login')) @@ -76,7 +107,13 @@ def settings_access_required(f): """ @wraps(f) def decorated_function(*args, **kwargs): - if 'user' not in session: + # Check if we're in no-auth mode and need to set up a user + if current_app.config.get('AUTH_MODE') == 'none': + redirect_response = _ensure_no_auth_user() + if redirect_response: + return redirect_response + elif 'user' not in session: + # Entra ID mode - redirect to login session['next'] = request.url return redirect(url_for('auth.login')) diff --git a/src/app/models/user.py b/src/app/models/user.py index 55a3ba3..372b193 100644 --- a/src/app/models/user.py +++ b/src/app/models/user.py @@ -67,8 +67,22 @@ class UserModel: return None @staticmethod - def get_by_id(user_id: int) -> Optional[Dict]: - """Get user by ID""" + def get_by_id(user_id) -> Optional[Dict]: + """Get user by ID (supports both integer IDs and string test IDs for no-auth mode)""" + # Handle test user IDs for no-auth mode + if isinstance(user_id, str) and user_id.startswith('test-'): + # Mock test user + role = 'admin' if 'admin' in user_id else 'user' + return { + 'id': user_id, + 'email': f'{role}@test.local', + 'azure_oid': None, + 'name': f'Test {role.capitalize()}', + 'role': role, + 'created_at': None, + 'updated_at': None + } + with get_db() as conn: cursor = conn.cursor() cursor.execute( @@ -211,13 +225,17 @@ class UserModel: return [row[0] for row in cursor.fetchall()] @staticmethod - def has_access_to_tenant(user_id: int, tenant_id: str) -> bool: + def has_access_to_tenant(user_id, tenant_id: str) -> bool: """Check if user has access to a specific tenant""" # Admin has access to everything user = UserModel.get_by_id(user_id) if user and user['role'] == UserModel.ROLE_ADMIN: return True + # Test users in no-auth mode don't have tenant associations + if isinstance(user_id, str) and user_id.startswith('test-'): + return False + # Check if user owns the tenant with get_db() as conn: cursor = conn.cursor() @@ -228,7 +246,7 @@ class UserModel: return cursor.fetchone() is not None @staticmethod - def is_admin(user_id: int) -> bool: + def is_admin(user_id) -> bool: """Check if user has admin role""" user = UserModel.get_by_id(user_id) return user and user['role'] == UserModel.ROLE_ADMIN diff --git a/src/app/templates/layout.html b/src/app/templates/layout.html index 262405e..81ee272 100644 --- a/src/app/templates/layout.html +++ b/src/app/templates/layout.html @@ -72,6 +72,15 @@ {% endif %}