Implement Microsoft Entra ID (Azure AD) authentication with user and admin roles. Features: - OAuth 2.0 authentication flow using MSAL - User and admin role-based access control - Tenant ownership and access management - Server-side session storage - Protected routes with authentication decorators Authentication: - Users authenticate via Microsoft organizational accounts - Admin role automatically assigned to DEFAULT_ADMIN_EMAIL - User profiles synced from Microsoft Graph API Access Control: - Admins: Full access to all tenants and server settings - Users: Can create and manage their own tenants only - Tenant association: Auto-created when user adds products - API endpoints remain open for AR application access Database Changes: - New users table with email, azure_oid, name, and role - New user_tenants table for many-to-many tenant ownership - Case-insensitive admin email comparison Configuration: - Environment-based config via .env file - Azure AD credentials (client ID, secret, tenant ID) - Configurable redirect URI and admin email - Updated to Flask 2.2.5 for compatibility
123 lines
3.9 KiB
Python
123 lines
3.9 KiB
Python
"""Authentication routes for Entra ID login/logout"""
|
|
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"""
|
|
# Generate state token for CSRF protection
|
|
state = secrets.token_urlsafe(16)
|
|
session['auth_state'] = state
|
|
|
|
# Store the original requested URL to redirect after login
|
|
if 'next' not in session and request.args.get('next'):
|
|
session['next'] = request.args.get('next')
|
|
|
|
# Get authorization URL from MSAL
|
|
auth_url = MSALService.get_auth_url(state=state)
|
|
|
|
return redirect(auth_url)
|
|
|
|
|
|
@auth_bp.route('/callback')
|
|
def callback():
|
|
"""Handle the callback from Entra ID after authentication"""
|
|
# Verify state to prevent CSRF
|
|
state = request.args.get('state')
|
|
if state != session.get('auth_state'):
|
|
current_app.logger.error("State mismatch in auth callback - possible CSRF attack")
|
|
flash('Authentication failed: Invalid state parameter', 'error')
|
|
return redirect(url_for('main.index'))
|
|
|
|
# Clear the state
|
|
session.pop('auth_state', None)
|
|
|
|
# Check for errors
|
|
if 'error' in request.args:
|
|
error = request.args.get('error')
|
|
error_description = request.args.get('error_description', 'Unknown error')
|
|
current_app.logger.error(f"Auth error: {error} - {error_description}")
|
|
flash(f'Authentication failed: {error_description}', 'error')
|
|
return redirect(url_for('main.index'))
|
|
|
|
# Get authorization code
|
|
auth_code = request.args.get('code')
|
|
if not auth_code:
|
|
flash('Authentication failed: No authorization code received', 'error')
|
|
return redirect(url_for('main.index'))
|
|
|
|
# Exchange code for token
|
|
token_response = MSALService.acquire_token_by_auth_code(auth_code)
|
|
|
|
if 'error' in token_response:
|
|
error = token_response.get('error')
|
|
error_description = token_response.get('error_description', 'Unknown error')
|
|
current_app.logger.error(f"Token error: {error} - {error_description}")
|
|
flash(f'Authentication failed: {error_description}', 'error')
|
|
return redirect(url_for('main.index'))
|
|
|
|
# Get user info from Microsoft Graph
|
|
access_token = token_response.get('access_token')
|
|
user_info = MSALService.get_user_info(access_token)
|
|
|
|
if not user_info:
|
|
flash('Failed to retrieve user information', 'error')
|
|
return redirect(url_for('main.index'))
|
|
|
|
# Get or create user in database
|
|
user = UserModel.get_or_create_from_azure(
|
|
email=user_info['email'],
|
|
name=user_info['name'],
|
|
azure_oid=user_info['azure_oid'],
|
|
default_admin_email=current_app.config['DEFAULT_ADMIN_EMAIL']
|
|
)
|
|
|
|
# Store user in session
|
|
session['user'] = {
|
|
'id': user['id'],
|
|
'email': user['email'],
|
|
'name': user['name'],
|
|
'role': user['role']
|
|
}
|
|
|
|
current_app.logger.info(f"User {user['email']} logged in successfully")
|
|
|
|
# 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'))
|
|
|
|
|
|
@auth_bp.route('/logout')
|
|
def logout():
|
|
"""Log out the current user"""
|
|
if 'user' in session:
|
|
user_email = session['user'].get('email')
|
|
current_app.logger.info(f"User {user_email} logged out")
|
|
|
|
# Clear session
|
|
session.clear()
|
|
|
|
# Remove from MSAL cache
|
|
MSALService.remove_account()
|
|
|
|
# Redirect to Azure AD logout
|
|
logout_url = (
|
|
f"{current_app.config['AUTHORITY']}{current_app.config['AZURE_TENANT_ID']}/oauth2/v2.0/logout"
|
|
f"?post_logout_redirect_uri={request.url_root}"
|
|
)
|
|
|
|
return redirect(logout_url)
|
|
|
|
|
|
@auth_bp.route('/unauthorized')
|
|
def unauthorized():
|
|
"""Show unauthorized access page"""
|
|
return render_template('unauthorized.html'), 403
|