Merge pull request #8 from mattintech/feature/entra-id
Add Entra ID authentication with optional no-auth mode
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 flask import session, redirect, url_for, request, current_app, render_template, flash, abort
|
||||
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':
|
||||
current_app.logger.warning(f"Attempted to access role selection in {current_app.config.get('AUTH_MODE')} mode")
|
||||
abort(403, description="Role selection is only available in no-auth mode")
|
||||
|
||||
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()
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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'))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -72,6 +72,15 @@
|
||||
{% endif %}
|
||||
</ul>
|
||||
<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 %}
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" id="userDropdown" role="button" data-bs-toggle="dropdown">
|
||||
|
||||
105
src/app/templates/select_role.html
Normal file
105
src/app/templates/select_role.html
Normal 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 %}
|
||||
Reference in New Issue
Block a user