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).
This commit is contained in:
2025-10-20 20:03:46 -04:00
parent 795068c015
commit 2b471ef09f
7 changed files with 245 additions and 12 deletions

View File

@@ -2,6 +2,10 @@
SECRET_KEY=your-secret-key-change-this-in-production SECRET_KEY=your-secret-key-change-this-in-production
FLASK_DEBUG=1 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 # Entra ID (Azure AD) Configuration
# Get these values from your Azure Portal > App Registrations # Get these values from your Azure Portal > App Registrations
AZURE_CLIENT_ID=your-client-id-here AZURE_CLIENT_ID=your-client-id-here

View File

@@ -1,14 +1,20 @@
"""Authentication routes for Entra ID login/logout""" """Authentication routes for Entra ID login/logout and no-auth mode"""
import secrets import secrets
from flask import session, redirect, url_for, request, current_app, render_template, flash from flask import session, redirect, url_for, request, current_app, render_template, flash
from . import auth_bp from . import auth_bp
from app.services.msal_service import MSALService
from app.models.user import UserModel from app.models.user import UserModel
@auth_bp.route('/login') @auth_bp.route('/login')
def 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 # Generate state token for CSRF protection
state = secrets.token_urlsafe(16) state = secrets.token_urlsafe(16)
session['auth_state'] = state session['auth_state'] = state
@@ -23,9 +29,51 @@ def login():
return redirect(auth_url) 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') @auth_bp.route('/callback')
def callback(): def callback():
"""Handle the callback from Entra ID after authentication""" """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 # Verify state to prevent CSRF
state = request.args.get('state') state = request.args.get('state')
if state != session.get('auth_state'): if state != session.get('auth_state'):
@@ -104,6 +152,14 @@ def logout():
# Clear session # Clear session
session.clear() 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 # Remove from MSAL cache
MSALService.remove_account() MSALService.remove_account()

View File

@@ -28,6 +28,10 @@ class Config:
SESSION_USE_SIGNER = True SESSION_USE_SIGNER = True
SESSION_FILE_DIR = os.path.join(DATA_FOLDER, 'flask_session') 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 # Entra ID (Azure AD) Configuration
AZURE_CLIENT_ID = os.environ.get('AZURE_CLIENT_ID') AZURE_CLIENT_ID = os.environ.get('AZURE_CLIENT_ID')
AZURE_CLIENT_SECRET = os.environ.get('AZURE_CLIENT_SECRET') AZURE_CLIENT_SECRET = os.environ.get('AZURE_CLIENT_SECRET')

View File

@@ -4,17 +4,36 @@ from flask import session, redirect, url_for, request, abort, current_app
from app.models.user import UserModel 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): def login_required(f):
""" """
Decorator to require user authentication. Decorator to require user authentication.
Redirects to login if user is not authenticated. Redirects to login if user is not authenticated.
In 'none' auth mode, redirects to role selection if no session exists.
""" """
@wraps(f) @wraps(f)
def decorated_function(*args, **kwargs): 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
# Store the requested URL to redirect back after login 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 session['next'] = request.url
return redirect(url_for('auth.login')) return redirect(url_for('auth.login'))
return f(*args, **kwargs) return f(*args, **kwargs)
return decorated_function return decorated_function
@@ -26,7 +45,13 @@ def admin_required(f):
""" """
@wraps(f) @wraps(f)
def decorated_function(*args, **kwargs): 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 session['next'] = request.url
return redirect(url_for('auth.login')) return redirect(url_for('auth.login'))
@@ -47,7 +72,13 @@ def tenant_access_required(f):
""" """
@wraps(f) @wraps(f)
def decorated_function(*args, **kwargs): 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 session['next'] = request.url
return redirect(url_for('auth.login')) return redirect(url_for('auth.login'))
@@ -76,7 +107,13 @@ def settings_access_required(f):
""" """
@wraps(f) @wraps(f)
def decorated_function(*args, **kwargs): 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 session['next'] = request.url
return redirect(url_for('auth.login')) return redirect(url_for('auth.login'))

View File

@@ -67,8 +67,22 @@ class UserModel:
return None return None
@staticmethod @staticmethod
def get_by_id(user_id: int) -> Optional[Dict]: def get_by_id(user_id) -> Optional[Dict]:
"""Get user by ID""" """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: with get_db() as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute( cursor.execute(
@@ -211,13 +225,17 @@ class UserModel:
return [row[0] for row in cursor.fetchall()] return [row[0] for row in cursor.fetchall()]
@staticmethod @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""" """Check if user has access to a specific tenant"""
# Admin has access to everything # Admin has access to everything
user = UserModel.get_by_id(user_id) user = UserModel.get_by_id(user_id)
if user and user['role'] == UserModel.ROLE_ADMIN: if user and user['role'] == UserModel.ROLE_ADMIN:
return True 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 # Check if user owns the tenant
with get_db() as conn: with get_db() as conn:
cursor = conn.cursor() cursor = conn.cursor()
@@ -228,7 +246,7 @@ class UserModel:
return cursor.fetchone() is not None return cursor.fetchone() is not None
@staticmethod @staticmethod
def is_admin(user_id: int) -> bool: def is_admin(user_id) -> bool:
"""Check if user has admin role""" """Check if user has admin role"""
user = UserModel.get_by_id(user_id) user = UserModel.get_by_id(user_id)
return user and user['role'] == UserModel.ROLE_ADMIN return user and user['role'] == UserModel.ROLE_ADMIN

View File

@@ -72,6 +72,15 @@
{% endif %} {% endif %}
</ul> </ul>
<ul class="navbar-nav ms-auto"> <ul class="navbar-nav ms-auto">
{% if config.AUTH_MODE == 'none' %}
<li class="nav-item">
<span class="nav-link">
<span class="badge bg-warning text-dark">
<i class="bi bi-shield-lock"></i> No Auth Mode
</span>
</span>
</li>
{% endif %}
{% if session.user %} {% if session.user %}
<li class="nav-item dropdown"> <li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="userDropdown" role="button" data-bs-toggle="dropdown"> <a class="nav-link dropdown-toggle" href="#" id="userDropdown" role="button" data-bs-toggle="dropdown">

View File

@@ -0,0 +1,105 @@
{% extends "layout.html" %}
{% block title %}Select Role - KCAP Demo Server{% endblock %}
{% block extra_css %}
<style>
.role-selection-container {
max-width: 600px;
margin: 100px auto;
}
.role-card {
transition: all 0.3s ease;
cursor: pointer;
border: 2px solid transparent;
}
.role-card:hover {
transform: translateY(-5px);
border-color: #0d6efd;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.role-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.auth-mode-badge {
position: fixed;
top: 80px;
right: 20px;
z-index: 1000;
}
</style>
{% endblock %}
{% block content %}
<span class="badge bg-warning text-dark auth-mode-badge">
<i class="bi bi-shield-lock"></i> No Auth Mode
</span>
<div class="role-selection-container">
<div class="text-center mb-4">
<h2>Welcome to KCAP Demo Server</h2>
<p class="text-muted">Select your role to continue</p>
<small class="text-muted">
<i class="bi bi-info-circle"></i> Running in no-auth mode for local testing
</small>
</div>
<div class="row g-4">
<div class="col-md-6">
<div class="card role-card h-100 text-center" onclick="selectRole('admin')">
<div class="card-body p-4">
<div class="role-icon text-danger">
<i class="bi bi-shield-fill-check"></i>
</div>
<h4>Admin</h4>
<p class="text-muted mb-0">
Full access to all tenants, server settings, and user management
</p>
<ul class="list-unstyled text-start mt-3">
<li><i class="bi bi-check-circle-fill text-success"></i> Manage all tenants</li>
<li><i class="bi bi-check-circle-fill text-success"></i> Server settings</li>
<li><i class="bi bi-check-circle-fill text-success"></i> User management</li>
</ul>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card role-card h-100 text-center" onclick="selectRole('user')">
<div class="card-body p-4">
<div class="role-icon text-primary">
<i class="bi bi-person-circle"></i>
</div>
<h4>User</h4>
<p class="text-muted mb-0">
Access to assigned tenants only
</p>
<ul class="list-unstyled text-start mt-3">
<li><i class="bi bi-check-circle-fill text-success"></i> Assigned tenants</li>
<li><i class="bi bi-x-circle-fill text-danger"></i> Server settings</li>
<li><i class="bi bi-x-circle-fill text-danger"></i> User management</li>
</ul>
</div>
</div>
</div>
</div>
<div class="alert alert-info mt-4" role="alert">
<i class="bi bi-lightbulb"></i>
<strong>Testing Mode:</strong> This selection is temporary and only for local development.
To use Entra ID authentication, set <code>AUTH_MODE=entra</code> in your .env file.
</div>
</div>
<form id="roleForm" method="POST" action="{{ url_for('auth.select_role') }}" style="display: none;">
<input type="hidden" name="role" id="roleInput">
</form>
<script>
function selectRole(role) {
document.getElementById('roleInput').value = role;
document.getElementById('roleForm').submit();
}
</script>
{% endblock %}