Add Entra ID authentication with role-based access control
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
This commit is contained in:
122
src/app/blueprints/auth/routes.py
Normal file
122
src/app/blueprints/auth/routes.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user