diff --git a/Docker/.env.example b/Docker/.env.example index 0c5f823..f7dd476 100644 --- a/Docker/.env.example +++ b/Docker/.env.example @@ -92,6 +92,9 @@ OIDC_ISSUER_URL= # OIDC scope (space-separated list of scopes) OIDC_SCOPE=openid email profile +# OIDC admin group (optional, requires group scope) +OIDC_ADMIN_GROUP= + ### **Development/Debugging Configuration (Optional)** # Flask environment (development/production) diff --git a/backend/__init__.py b/backend/__init__.py index 1f4dbbd..c5f99bc 100644 --- a/backend/__init__.py +++ b/backend/__init__.py @@ -108,71 +108,6 @@ def ensure_owner_exists(): if conn: release_db_connection(conn) -def init_oidc_client(current_app_instance, db_conn_func, db_release_func): - """Function to initialize OIDC client based on settings""" - from .extensions import oauth - - logger.info("[FACTORY OIDC_INIT] Attempting to initialize OIDC client...") - conn = None - oidc_db_settings = {} - try: - conn = db_conn_func() - if conn: - with conn.cursor() as cur: - cur.execute("SELECT key, value FROM site_settings WHERE key LIKE 'oidc_%%'") - for row in cur.fetchall(): - oidc_db_settings[row[0]] = row[1] - logger.info(f"[FACTORY OIDC_INIT] Fetched OIDC settings from DB: {oidc_db_settings}") - else: - logger.error("[FACTORY OIDC_INIT] Database connection failed, cannot fetch OIDC settings from DB.") - except Exception as e: - logger.error(f"[FACTORY OIDC_INIT] Error fetching OIDC settings from DB: {e}. Proceeding without DB settings.") - finally: - if conn: - db_release_func(conn) - - # Priority: Environment Variable > Database Setting > Hardcoded Default - # Check Environment Variable first for OIDC enabled - oidc_enabled_from_env = os.environ.get('OIDC_ENABLED') - if oidc_enabled_from_env is not None: - # If the environment variable is set (even to 'false'), it takes highest priority - is_enabled = oidc_enabled_from_env.lower() == 'true' - else: - # If no environment variable, fall back to the database setting - oidc_enabled_from_db = oidc_db_settings.get('oidc_enabled', 'false') # Default to 'false' if not in DB - is_enabled = oidc_enabled_from_db.lower() == 'true' - - current_app_instance.config['OIDC_ENABLED'] = is_enabled - logger.info(f"[FACTORY OIDC_INIT] OIDC enabled status: {is_enabled}") - - if is_enabled: - # Apply same precedence logic to all OIDC settings - provider_name = os.environ.get('OIDC_PROVIDER_NAME', oidc_db_settings.get('oidc_provider_name', 'oidc')) - client_id = os.environ.get('OIDC_CLIENT_ID', oidc_db_settings.get('oidc_client_id', '')) - client_secret = os.environ.get('OIDC_CLIENT_SECRET', oidc_db_settings.get('oidc_client_secret', '')) - issuer_url = os.environ.get('OIDC_ISSUER_URL', oidc_db_settings.get('oidc_issuer_url', '')) - scope = os.environ.get('OIDC_SCOPE', oidc_db_settings.get('oidc_scope', 'openid email profile')) - - current_app_instance.config['OIDC_PROVIDER_NAME'] = provider_name - - if client_id and client_secret and issuer_url: - logger.info(f"[FACTORY OIDC_INIT] Registering OIDC client '{provider_name}' with Authlib.") - oauth.register( - name=provider_name, - client_id=client_id, - client_secret=client_secret, - server_metadata_url=f"{issuer_url.rstrip('/')}/.well-known/openid-configuration", - client_kwargs={'scope': scope}, - override=True - ) - logger.info(f"[FACTORY OIDC_INIT] OIDC client '{provider_name}' registered successfully.") - else: - logger.warning("[FACTORY OIDC_INIT] OIDC is enabled, but critical parameters are missing. OIDC login will be unavailable.") - current_app_instance.config['OIDC_ENABLED'] = False - else: - current_app_instance.config['OIDC_PROVIDER_NAME'] = None - logger.info("[FACTORY OIDC_INIT] OIDC is disabled.") - def create_app(config_name=None): """Create and configure an instance of the Flask application.""" # Configure logging FIRST @@ -248,9 +183,11 @@ def create_app(config_name=None): # Initialize OIDC client after extensions and blueprints try: from .db_handler import get_db_connection, release_db_connection + from .oidc_handler import init_oidc_client init_oidc_client(app, get_db_connection, release_db_connection) except ImportError: from db_handler import get_db_connection, release_db_connection + from oidc_handler import init_oidc_client init_oidc_client(app, get_db_connection, release_db_connection) # Ensure an owner exists on startup diff --git a/backend/admin_routes.py b/backend/admin_routes.py index 57260ea..6f065fa 100644 --- a/backend/admin_routes.py +++ b/backend/admin_routes.py @@ -363,6 +363,7 @@ def get_site_settings(): # 'oidc_client_secret': '', # Not returned 'oidc_issuer_url': '', 'oidc_scope': 'openid email profile', + 'oidc_admin_group': '', # Apprise default settings 'apprise_enabled': 'false', 'apprise_urls': '', diff --git a/backend/auth_routes.py b/backend/auth_routes.py index 6d54b73..b5b900e 100644 --- a/backend/auth_routes.py +++ b/backend/auth_routes.py @@ -1,7 +1,7 @@ # backend/auth_routes.py # Updated: 2025-01-24 - Fixed API endpoints for notifications from flask import Blueprint, request, jsonify, current_app -from datetime import datetime, timedelta +from datetime import datetime, UTC, timedelta import uuid import smtplib from email.mime.multipart import MIMEMultipart @@ -117,13 +117,13 @@ def register(): token = generate_token(user_id) # Update last login - cur.execute('UPDATE users SET last_login = %s WHERE id = %s', (datetime.utcnow(), user_id)) + cur.execute('UPDATE users SET last_login = %s WHERE id = %s', (datetime.now(UTC), user_id)) # Store session info ip_address = request.remote_addr user_agent = request.headers.get('User-Agent', '') session_token = str(uuid.uuid4()) - expires_at = datetime.utcnow() + current_app.config['JWT_EXPIRATION_DELTA'] + expires_at = datetime.now(UTC) + current_app.config['JWT_EXPIRATION_DELTA'] cur.execute( 'INSERT INTO user_sessions (user_id, session_token, expires_at, ip_address, user_agent, login_method) VALUES (%s, %s, %s, %s, %s, %s)', @@ -182,13 +182,13 @@ def login(): token = generate_token(user_id) # Update last login - cur.execute('UPDATE users SET last_login = %s WHERE id = %s', (datetime.utcnow(), user_id)) + cur.execute('UPDATE users SET last_login = %s WHERE id = %s', (datetime.now(UTC), user_id)) # Store session info ip_address = request.remote_addr user_agent = request.headers.get('User-Agent', '') session_token = str(uuid.uuid4()) - expires_at = datetime.utcnow() + current_app.config['JWT_EXPIRATION_DELTA'] + expires_at = datetime.now(UTC) + current_app.config['JWT_EXPIRATION_DELTA'] cur.execute( 'INSERT INTO user_sessions (user_id, session_token, expires_at, ip_address, user_agent, login_method) VALUES (%s, %s, %s, %s, %s, %s)', @@ -204,7 +204,8 @@ def login(): 'id': user_id, 'username': user[1], 'email': user[2], - 'is_admin': user[5] # Include is_admin flag + 'is_admin': user[5], + 'oidc_managed': False } }), 200 except Exception as e: @@ -258,7 +259,8 @@ def validate_token(): 'username': request.user['username'], 'email': request.user['email'], 'is_admin': request.user['is_admin'], - 'is_owner': request.user.get('is_owner', False) + 'is_owner': request.user.get('is_owner', False), + 'oidc_managed': request.user['oidc_managed'] }, 'message': 'Token is valid' }), 200 @@ -282,12 +284,12 @@ def get_user(): # Try to get user with is_owner column, fall back to without it if column doesn't exist try: cur.execute( - 'SELECT id, username, email, first_name, last_name, is_admin, is_owner FROM users WHERE id = %s', + 'SELECT id, username, email, first_name, last_name, is_admin, is_owner, oidc_sub FROM users WHERE id = %s', (user_id,) ) user_data = cur.fetchone() has_owner_column = True - columns = ['id', 'username', 'email', 'first_name', 'last_name', 'is_admin', 'is_owner'] + columns = ['id', 'username', 'email', 'first_name', 'last_name', 'is_admin', 'is_owner', 'oidc_managed'] except Exception as e: # If the query fails (likely because is_owner column doesn't exist), rollback and try again current_app.logger.warning(f"Failed to query with is_owner column in get_user, falling back: {e}") @@ -298,7 +300,7 @@ def get_user(): ) user_data = cur.fetchone() has_owner_column = False - columns = ['id', 'username', 'email', 'first_name', 'last_name', 'is_admin'] + columns = ['id', 'username', 'email', 'first_name', 'last_name', 'is_admin', 'oidc_managed'] # --- END DATABASE QUERY --- if not user_data: @@ -311,6 +313,9 @@ def get_user(): if not has_owner_column: user_info['is_owner'] = False + # Turn oidc_sub into a boolean + user_info['oidc_managed'] = user_info['oidc_managed'] is not None + # Return the full user information return jsonify(user_info), 200 @@ -347,7 +352,7 @@ def request_password_reset(): # Generate reset token reset_token = str(uuid.uuid4()) - expires_at = datetime.utcnow() + timedelta(hours=24) + expires_at = datetime.now(UTC) + timedelta(hours=24) # Delete any existing tokens for this user cur.execute('DELETE FROM password_reset_tokens WHERE user_id = %s', (user_id,)) @@ -656,7 +661,7 @@ def reset_password(): cur.execute('SELECT user_id, expires_at FROM password_reset_tokens WHERE token = %s', (token,)) token_info = cur.fetchone() - if not token_info or token_info[1] < datetime.utcnow(): + if not token_info or token_info[1] < datetime.now(UTC): return jsonify({'message': 'Invalid or expired token!'}), 400 user_id = token_info[0] @@ -745,6 +750,8 @@ def send_password_reset_email(recipient_email, reset_link): smtp_port = int(os.environ.get('SMTP_PORT', 1025)) smtp_username = os.environ.get('SMTP_USERNAME') smtp_password = os.environ.get('SMTP_PASSWORD') + if os.environ.get('SMTP_PASSWORD_FILE'): + smtp_password = open(os.environ.get('SMTP_PASSWORD_FILE'), 'r').read().strip() smtp_use_tls = os.environ.get('SMTP_USE_TLS', 'true').lower() == 'true' smtp_use_ssl = os.environ.get('SMTP_USE_SSL', 'false').lower() == 'true' sender_email = os.environ.get('SMTP_SENDER_EMAIL', 'noreply@warracker.com') diff --git a/backend/auth_utils.py b/backend/auth_utils.py index 02a75e1..e5a4510 100644 --- a/backend/auth_utils.py +++ b/backend/auth_utils.py @@ -1,6 +1,6 @@ # backend/auth_utils.py import jwt -from datetime import datetime, timedelta +from datetime import datetime, UTC, timedelta from flask import current_app, request, jsonify from functools import wraps import re @@ -14,9 +14,9 @@ except ImportError: def generate_token(user_id): """Generate a JWT token for the user""" payload = { - 'exp': datetime.utcnow() + current_app.config['JWT_EXPIRATION_DELTA'], - 'iat': datetime.utcnow(), - 'sub': user_id + 'exp': datetime.now(UTC) + current_app.config['JWT_EXPIRATION_DELTA'], + 'iat': datetime.now(UTC), + 'sub': str(user_id) } return jwt.encode(payload, current_app.config['SECRET_KEY'], algorithm='HS256') @@ -67,7 +67,7 @@ def token_required(f): with conn.cursor() as cur: # Try to get user with is_owner column, fall back to without it if column doesn't exist try: - cur.execute('SELECT id, username, email, is_admin, is_owner FROM users WHERE id = %s AND is_active = TRUE', (user_id,)) + cur.execute('SELECT id, username, email, is_admin, is_owner, oidc_sub FROM users WHERE id = %s AND is_active = TRUE', (user_id,)) user = cur.fetchone() has_owner_column = True except Exception as e: @@ -87,8 +87,13 @@ def token_required(f): 'username': user[1], 'email': user[2], 'is_admin': user[3], - 'is_owner': user[4] if has_owner_column and len(user) > 4 else False } + + if has_owner_column: + request.user.update({ + 'is_owner': user[4], + 'oidc_managed': user[5] is not None + }) return f(*args, **kwargs) except Exception as e: diff --git a/backend/config.py b/backend/config.py index 686a7f6..226e095 100644 --- a/backend/config.py +++ b/backend/config.py @@ -5,11 +5,28 @@ from datetime import timedelta logger = logging.getLogger(__name__) +def get_try_create_secret(): + secret = os.environ.get('SECRET_KEY') + if not secret: + secret_file = 'secret_key' + if os.path.exists(secret_file): + secret = open(secret_file, 'r').read().strip() + else: + try: + import base64 + secret = base64.b64encode(os.urandom(32)).decode('ascii') + with open(secret_file, 'w') as f: + f.write(secret) + logger.info(f"Generated new SECRET_KEY and saved to {secret_file}") + except Exception: + secret = 'your_default_secret_key_please_change_in_prod' + return secret + class Config: """Base configuration class.""" # Flask Core Configuration - SECRET_KEY = os.environ.get('SECRET_KEY', 'your_default_secret_key_please_change_in_prod') + SECRET_KEY = get_try_create_secret() JWT_EXPIRATION_DELTA = timedelta(hours=int(os.environ.get('JWT_EXPIRATION_HOURS', '24'))) # Security Warning for Default Secret Key @@ -27,7 +44,7 @@ class Config: DB_ADMIN_PASSWORD = os.environ.get('DB_ADMIN_PASSWORD', 'change_this_password_in_production') # File Upload Configuration - UPLOAD_FOLDER = '/data/uploads' + UPLOAD_FOLDER = os.environ.get('UPLOAD_FOLDER', '/data/uploads') DEFAULT_MAX_UPLOAD_MB = 32 @staticmethod @@ -68,6 +85,13 @@ class Config: def init_app(app): """Initialize configuration-specific settings.""" Config._check_secret_key() + + if not os.path.exists(Config.UPLOAD_FOLDER): + try: + os.makedirs(Config.UPLOAD_FOLDER) + logger.info(f"Created upload folder at {Config.UPLOAD_FOLDER}") + except Exception as e: + logger.error(f"Failed to create upload folder at {Config.UPLOAD_FOLDER}: {e}") # Set upload configuration max_upload_mb = Config._get_max_upload_mb() diff --git a/backend/file_routes.py b/backend/file_routes.py index c74e247..88c0957 100644 --- a/backend/file_routes.py +++ b/backend/file_routes.py @@ -41,8 +41,8 @@ def serve_file(filename): # Remove 'uploads/' prefix for send_from_directory file_path = filename[8:] if filename.startswith('uploads/') else filename - - return send_from_directory('/data/uploads', file_path) + + return send_from_directory(current_app.config['UPLOAD_FOLDER'], file_path) except Exception as e: logger.error(f"Error serving file {filename}: {e}") return jsonify({"message": "Error accessing file"}), 500 @@ -121,21 +121,23 @@ def secure_file_access(filename): if not authorized: logger.warning(f"[SECURE_FILE] Unauthorized file access attempt: '{filename}' (repr: {repr(filename)}) by user {user_id}. DB results count: {len(results) if results else 'None'}") return jsonify({"message": "You are not authorized to access this file"}), 403 + + upload_dir = current_app.config['UPLOAD_FOLDER'] - logger.info(f"[SECURE_FILE] User {user_id} authorized for file '{filename}'. Attempting to serve from /data/uploads.") + logger.info(f"[SECURE_FILE] User {user_id} authorized for file '{filename}'. Attempting to serve from {upload_dir}.") # Construct the full file path - target_file_path_for_send = os.path.join('/data/uploads', filename) + target_file_path_for_send = os.path.join(upload_dir, filename) logger.info(f"[SECURE_FILE] Path for verification: '{target_file_path_for_send}' (repr: {repr(target_file_path_for_send)})") # Enhanced file existence and readability checks if not os.path.exists(target_file_path_for_send): logger.error(f"[SECURE_FILE] File '{target_file_path_for_send}' does not exist") try: - dir_contents = os.listdir('/data/uploads') - logger.info(f"[SECURE_FILE] Contents of /data/uploads: {dir_contents}") + dir_contents = os.listdir(upload_dir) + logger.info(f"[SECURE_FILE] Contents of {upload_dir}: {dir_contents}") except Exception as list_err: - logger.error(f"[SECURE_FILE] Error listing /data/uploads: {list_err}") + logger.error(f"[SECURE_FILE] Error listing {upload_dir}: {list_err}") return jsonify({"message": "File not found"}), 404 if not os.path.isfile(target_file_path_for_send): diff --git a/backend/notifications.py b/backend/notifications.py index 5a72648..b08873e 100644 --- a/backend/notifications.py +++ b/backend/notifications.py @@ -12,7 +12,7 @@ import time import atexit import smtplib import logging -from datetime import datetime, date, timedelta +from datetime import datetime, date, UTC, timedelta from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from threading import Lock @@ -299,6 +299,8 @@ def process_email_notifications(all_warranties, eligible_user_ids, is_manual, ge smtp_port = int(os.environ.get('SMTP_PORT', '1025')) smtp_username = os.environ.get('SMTP_USERNAME', 'notifications@warracker.com') smtp_password = os.environ.get('SMTP_PASSWORD', '') + if os.environ.get('SMTP_PASSWORD_FILE'): + smtp_password = open(os.environ.get('SMTP_PASSWORD_FILE'), 'r').read().strip() smtp_use_tls_env = os.environ.get('SMTP_USE_TLS', 'not_set').lower() # For manual triggers, check email preferences @@ -353,7 +355,7 @@ def process_email_notifications(all_warranties, eligible_user_ids, is_manual, ge server.login(smtp_username, smtp_password) emails_sent = 0 - utc_now = datetime.utcnow() + utc_now = datetime.now(UTC) timestamp = int(utc_now.timestamp()) for email, user_data in users_warranties.items(): @@ -535,7 +537,7 @@ def send_expiration_notifications(manual_trigger=False, get_db_connection=None, if not manual_trigger: with conn.cursor() as cur: - utc_now = datetime.utcnow() + utc_now = datetime.now(UTC) # Check if required columns exist for dynamic query building cur.execute(""" diff --git a/backend/oidc_handler.py b/backend/oidc_handler.py index 26f6c24..7f2f0a8 100644 --- a/backend/oidc_handler.py +++ b/backend/oidc_handler.py @@ -1,7 +1,7 @@ # backend/oidc_handler.py import os import uuid -from datetime import datetime # Ensure timedelta is imported if used, though not in this snippet +from datetime import datetime, UTC # Ensure timedelta is imported if used, though not in this snippet from flask import Blueprint, jsonify, redirect, url_for, current_app, request, session # Import shared extensions and utilities @@ -21,6 +21,74 @@ logger = logging.getLogger(__name__) # Or use current_app.logger inside routes oidc_bp = Blueprint('oidc', __name__) # url_prefix will be set when registering in app.py +def init_oidc_client(current_app_instance, db_conn_func, db_release_func): + """Function to initialize OIDC client based on settings""" + from .extensions import oauth + + logger.info("[FACTORY OIDC_INIT] Attempting to initialize OIDC client...") + conn = None + oidc_db_settings = {} + try: + conn = db_conn_func() + if conn: + with conn.cursor() as cur: + cur.execute("SELECT key, value FROM site_settings WHERE key LIKE 'oidc_%%'") + for row in cur.fetchall(): + oidc_db_settings[row[0]] = row[1] + logger.info(f"[FACTORY OIDC_INIT] Fetched OIDC settings from DB: {oidc_db_settings}") + else: + logger.error("[FACTORY OIDC_INIT] Database connection failed, cannot fetch OIDC settings from DB.") + except Exception as e: + logger.error(f"[FACTORY OIDC_INIT] Error fetching OIDC settings from DB: {e}. Proceeding without DB settings.") + finally: + if conn: + db_release_func(conn) + + # Priority: Environment Variable > Database Setting > Hardcoded Default + # Check Environment Variable first for OIDC enabled + oidc_enabled_from_env = os.environ.get('OIDC_ENABLED') + if oidc_enabled_from_env is not None: + # If the environment variable is set (even to 'false'), it takes highest priority + is_enabled = oidc_enabled_from_env.lower() == 'true' + else: + # If no environment variable, fall back to the database setting + oidc_enabled_from_db = oidc_db_settings.get('oidc_enabled', 'false') # Default to 'false' if not in DB + is_enabled = oidc_enabled_from_db.lower() == 'true' + + current_app_instance.config['OIDC_ENABLED'] = is_enabled + logger.info(f"[FACTORY OIDC_INIT] OIDC enabled status: {is_enabled}") + + if is_enabled: + # Apply same precedence logic to all OIDC settings + provider_name = os.environ.get('OIDC_PROVIDER_NAME', oidc_db_settings.get('oidc_provider_name', 'oidc')) + client_id = os.environ.get('OIDC_CLIENT_ID', oidc_db_settings.get('oidc_client_id', '')) + client_secret = os.environ.get('OIDC_CLIENT_SECRET', oidc_db_settings.get('oidc_client_secret', '')) + if os.environ.get('OIDC_CLIENT_SECRET_FILE'): + client_secret = open(os.environ.get('OIDC_CLIENT_SECRET_FILE'), 'r').read().strip() + issuer_url = os.environ.get('OIDC_ISSUER_URL', oidc_db_settings.get('oidc_issuer_url', '')) + scope = os.environ.get('OIDC_SCOPE', oidc_db_settings.get('oidc_scope', 'openid email profile')) + + current_app_instance.config['OIDC_PROVIDER_NAME'] = provider_name + + if client_id and client_secret and issuer_url: + logger.info(f"[FACTORY OIDC_INIT] Registering OIDC client '{provider_name}' with Authlib.") + oauth.register( + name=provider_name, + client_id=client_id, + client_secret=client_secret, + server_metadata_url=f"{issuer_url.rstrip('/')}/.well-known/openid-configuration", + client_kwargs={'scope': scope}, + override=True + ) + logger.info(f"[FACTORY OIDC_INIT] OIDC client '{provider_name}' registered successfully.") + else: + logger.warning("[FACTORY OIDC_INIT] OIDC is enabled, but critical parameters are missing. OIDC login will be unavailable.") + current_app_instance.config['OIDC_ENABLED'] = False + else: + current_app_instance.config['OIDC_PROVIDER_NAME'] = None + logger.info("[FACTORY OIDC_INIT] OIDC is disabled.") + + @oidc_bp.route('/oidc/login') # Original path was /api/oidc/login def oidc_login_route(): if not current_app.config.get('OIDC_ENABLED'): @@ -68,43 +136,80 @@ def oidc_callback_route(): frontend_login_url = os.environ.get('FRONTEND_URL', 'http://localhost:8080').rstrip('/') + "/login.html" return redirect(f"{frontend_login_url}?oidc_error=token_missing") - userinfo = token_data.get('userinfo') - if not userinfo: - try: - userinfo = client.userinfo(token=token_data) - except Exception as e: - logger.error(f"[OIDC_HANDLER] OIDC callback error fetching userinfo: {e}") - frontend_login_url = os.environ.get('FRONTEND_URL', 'http://localhost:8080').rstrip('/') + "/login.html" - return redirect(f"{frontend_login_url}?oidc_error=userinfo_fetch_failed") + token_id_claims = token_data.get('userinfo') + if not token_id_claims: + logger.error("[OIDC_HANDLER] OIDC callback: Failed to retrieve token userinfo.") + frontend_login_url = os.environ.get('FRONTEND_URL', 'http://localhost:8080').rstrip('/') + "/login.html" + return redirect(f"{frontend_login_url}?oidc_error=userinfo_missing") + + try: + userinfo = client.userinfo(token=token_data) + except Exception as e: + logger.error(f"[OIDC_HANDLER] OIDC callback error fetching userinfo: {e}") + frontend_login_url = os.environ.get('FRONTEND_URL', 'http://localhost:8080').rstrip('/') + "/login.html" + return redirect(f"{frontend_login_url}?oidc_error=userinfo_fetch_failed") if not userinfo: logger.error("[OIDC_HANDLER] OIDC callback: Failed to retrieve userinfo.") frontend_login_url = os.environ.get('FRONTEND_URL', 'http://localhost:8080').rstrip('/') + "/login.html" return redirect(f"{frontend_login_url}?oidc_error=userinfo_missing") - oidc_subject = userinfo.get('sub') - oidc_issuer = userinfo.get('iss') + oidc_subject = token_id_claims.get('sub') + oidc_issuer = token_id_claims.get('iss') if not oidc_subject: - logger.error("[OIDC_HANDLER] OIDC callback: 'sub' (subject) missing in userinfo.") + logger.error("[OIDC_HANDLER] OIDC callback: 'sub' (subject) missing in token userinfo.") frontend_login_url = os.environ.get('FRONTEND_URL', 'http://localhost:8080').rstrip('/') + "/login.html" return redirect(f"{frontend_login_url}?oidc_error=subject_missing") + email = token_id_claims.get('email') or userinfo.get('email') + + first_name = token_id_claims.get('given_name') or userinfo.get('given_name', '') + last_name = token_id_claims.get('family_name') or userinfo.get('family_name', '') + + if not first_name and not last_name: + first_name = token_id_claims.get('name') or userinfo.get('name', '') + + user_groups = token_id_claims.get('groups') or userinfo.get('groups') or [] + conn = None try: conn = get_db_connection() with conn.cursor() as cur: # Check for existing OIDC user - cur.execute("SELECT id, username, email, is_admin FROM users WHERE oidc_sub = %s AND oidc_issuer = %s AND is_active = TRUE", + cur.execute("SELECT id, username, email, first_name, last_name, is_admin FROM users WHERE oidc_sub = %s AND oidc_issuer = %s AND is_active = TRUE", (oidc_subject, oidc_issuer)) user_db_data = cur.fetchone() user_id = None is_new_user = False + admin_oidc_group = os.environ.get('OIDC_ADMIN_GROUP') + if not admin_oidc_group: + cur.execute("SELECT value FROM site_settings WHERE key = 'admin_oidc_group'") + result = cur.fetchone() + if result: + admin_oidc_group = result[0] + if user_db_data: user_id = user_db_data[0] logger.info(f"[OIDC_HANDLER] Existing OIDC user found with ID {user_id} for sub {oidc_subject}") + + # Update any changed user info + if email and email != user_db_data[2]: + cur.execute('UPDATE users SET email = %s WHERE id = %s', (email, user_id)) + logger.info(f"[OIDC_HANDLER] Updated email for OIDC user ID {user_id} to {email}") + if first_name and first_name != user_db_data[3]: + cur.execute('UPDATE users SET first_name = %s WHERE id = %s', (first_name, user_id)) + logger.info(f"[OIDC_HANDLER] Updated first name for OIDC user ID {user_id} to {first_name}") + if last_name and last_name != user_db_data[4]: + cur.execute('UPDATE users SET last_name = %s WHERE id = %s', (last_name, user_id)) + logger.info(f"[OIDC_HANDLER] Updated last name for OIDC user ID {user_id} to {last_name}") + if admin_oidc_group: + is_admin = admin_oidc_group in user_groups + if is_admin != user_db_data[5]: + cur.execute('UPDATE users SET is_admin = %s WHERE id = %s', (is_admin, user_id)) + logger.info(f"[OIDC_HANDLER] Updated admin status for OIDC user ID {user_id} to {is_admin} based on group membership.") else: # Check if registration is enabled before creating new users cur.execute(""" @@ -136,7 +241,6 @@ def oidc_callback_route(): # New user provisioning is_new_user = True - email = userinfo.get('email') if not email: logger.error("[OIDC_HANDLER] 'email' missing in userinfo for new OIDC user.") frontend_login_url = os.environ.get('FRONTEND_URL', 'http://localhost:8080').rstrip('/') + "/login.html" @@ -149,36 +253,39 @@ def oidc_callback_route(): frontend_login_url = os.environ.get('FRONTEND_URL', 'http://localhost:8080').rstrip('/') + "/login.html" return redirect(f"{frontend_login_url}?oidc_error=email_conflict_local_account") - username = userinfo.get('preferred_username') or userinfo.get('name') or email.split('@')[0] + username = token_id_claims.get('preferred_username') or userinfo.get('preferred_username') or \ + token_id_claims.get('name') or userinfo.get('name') or \ + email.split('@')[0] # Ensure username uniqueness cur.execute("SELECT id FROM users WHERE username = %s", (username,)) if cur.fetchone(): username = f"{username}_{str(uuid.uuid4())[:4]}" # Short random suffix - first_name = userinfo.get('given_name', '') - last_name = userinfo.get('family_name', '') - cur.execute('SELECT COUNT(*) FROM users') user_count = cur.fetchone()[0] - - # Determine admin status: first user OR email matches configured admin email - is_first_user_admin = (user_count == 0) - - admin_email_from_env = current_app.config.get('ADMIN_EMAIL', '').lower() - oidc_user_email_lower = email.lower() if email else '' - - is_email_match_admin = False - if admin_email_from_env and oidc_user_email_lower == admin_email_from_env: - is_email_match_admin = True - logger.info(f"[OIDC_HANDLER] New OIDC user email {oidc_user_email_lower} matches ADMIN_EMAIL {admin_email_from_env}.") - is_admin = is_first_user_admin or is_email_match_admin - - if is_admin and not is_first_user_admin: - logger.info(f"[OIDC_HANDLER] Granting admin rights to new OIDC user {oidc_user_email_lower} based on email match.") - elif is_first_user_admin: - logger.info(f"[OIDC_HANDLER] Granting admin rights to new OIDC user {oidc_user_email_lower} as they are the first user.") + if admin_oidc_group: + is_admin = admin_oidc_group in user_groups + if is_admin: + logger.info(f"[OIDC_HANDLER] New OIDC user {username} granted admin via OIDC group '{admin_oidc_group}'.") + else: + # Determine admin status: first user OR email matches configured admin email + is_first_user_admin = (user_count == 0) + admin_email_from_env = current_app.config.get('ADMIN_EMAIL', '').lower() + oidc_user_email_lower = email.lower() if email else '' + + is_email_match_admin = False + if admin_email_from_env and oidc_user_email_lower == admin_email_from_env: + is_email_match_admin = True + logger.info(f"[OIDC_HANDLER] New OIDC user email {oidc_user_email_lower} matches ADMIN_EMAIL {admin_email_from_env}.") + + is_admin = is_first_user_admin or is_email_match_admin + + if is_admin and not is_first_user_admin: + logger.info(f"[OIDC_HANDLER] Granting admin rights to new OIDC user {oidc_user_email_lower} based on email match.") + elif is_first_user_admin: + logger.info(f"[OIDC_HANDLER] Granting admin rights to new OIDC user {oidc_user_email_lower} as they are the first user.") # Insert new OIDC user cur.execute( @@ -193,14 +300,14 @@ def oidc_callback_route(): app_session_token = generate_token(user_id) # Generate app-specific JWT # Update last login timestamp - cur.execute('UPDATE users SET last_login = %s WHERE id = %s', (datetime.utcnow(), user_id)) + cur.execute('UPDATE users SET last_login = %s WHERE id = %s', (datetime.now(UTC), user_id)) # Log OIDC session in user_sessions table ip_address = request.remote_addr user_agent = request.headers.get('User-Agent', '') # Use a different UUID for session_token in DB if needed, or re-use app_session_token if appropriate for your session model db_session_token = str(uuid.uuid4()) - expires_at = datetime.utcnow() + current_app.config['JWT_EXPIRATION_DELTA'] + expires_at = datetime.now(UTC) + current_app.config['JWT_EXPIRATION_DELTA'] cur.execute( 'INSERT INTO user_sessions (user_id, session_token, expires_at, ip_address, user_agent, login_method) VALUES (%s, %s, %s, %s, %s, %s)', diff --git a/env.example b/env.example index be72d24..a092ea3 100644 --- a/env.example +++ b/env.example @@ -27,6 +27,7 @@ OIDC_CLIENT_ID=your_oidc_client_id OIDC_CLIENT_SECRET=your_oidc_client_secret OIDC_ISSUER_URL=https://your-oidc-provider.com/auth/realms/your-realm OIDC_SCOPE=openid email profile +OIDC_ADMIN_GROUP=admin # Requires group scope # Memory and Performance Settings WARRACKER_MEMORY_MODE=optimized diff --git a/frontend/about.html b/frontend/about.html index d04e380..b95a354 100644 --- a/frontend/about.html +++ b/frontend/about.html @@ -299,11 +299,12 @@
- - About + + About +
- Logout + Logout
diff --git a/frontend/header-fix.css b/frontend/header-fix.css index 8c96f11..d583f48 100644 --- a/frontend/header-fix.css +++ b/frontend/header-fix.css @@ -146,13 +146,17 @@ header .container { } .user-menu-item { - padding: 8px 15px !important; cursor: pointer !important; transition: background-color 0.3s !important; display: flex !important; align-items: center !important; } +.user-menu-item > :first-child { + padding: 8px 15px !important; + width: 100% !important; +} + .user-menu-item:hover { background-color: rgba(0, 0, 0, 0.05) !important; } diff --git a/frontend/index.html b/frontend/index.html index 289f052..65c5536 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -139,11 +139,12 @@
- - About + + About +
- Logout + Logout
diff --git a/frontend/mobile-header.css b/frontend/mobile-header.css index 12d8c88..ebad78e 100644 --- a/frontend/mobile-header.css +++ b/frontend/mobile-header.css @@ -197,7 +197,6 @@ } header .user-menu-item { - padding: 8px 15px !important; cursor: pointer !important; transition: background-color 0.3s !important; display: flex !important; @@ -205,6 +204,11 @@ color: var(--text-color) !important; } + header .user-menu-item > :first-child { + padding: 8px 15px !important; + width: 100% !important; + } + header .user-menu-item:hover { background-color: rgba(0, 0, 0, 0.05) !important; } @@ -1151,4 +1155,4 @@ font-size: 0.75rem !important; padding: 2px 5px !important; } -} \ No newline at end of file +} diff --git a/frontend/settings-new.html b/frontend/settings-new.html index b07dacb..44e34c0 100644 --- a/frontend/settings-new.html +++ b/frontend/settings-new.html @@ -131,11 +131,12 @@
- - About + + About +
- Logout + Logout
@@ -414,7 +415,7 @@ -
+

Security

@@ -633,6 +634,12 @@ Space-separated OIDC scopes.
+ +
+ + + Group that is given admin privileges. Leave blank to disable. +
diff --git a/frontend/settings-new.js b/frontend/settings-new.js index 8123f40..afba5ac 100644 --- a/frontend/settings-new.js +++ b/frontend/settings-new.js @@ -65,6 +65,7 @@ const oidcClientIdInput = document.getElementById('oidcClientId'); const oidcClientSecretInput = document.getElementById('oidcClientSecret'); const oidcIssuerUrlInput = document.getElementById('oidcIssuerUrl'); const oidcScopeInput = document.getElementById('oidcScope'); +const oidcAdminGroupInput = document.getElementById('oidcAdminGroup'); const saveOidcSettingsBtn = document.getElementById('saveOidcSettingsBtn'); const oidcRestartMessage = document.getElementById('oidcRestartMessage'); @@ -615,6 +616,20 @@ async function loadUserData() { } if (userNameDisplay) userNameDisplay.textContent = displayName; if (userEmailDisplay) userEmailDisplay.textContent = currentUser.email || 'N/A'; + + if (currentUser.oidc_managed) { + if (firstNameInput) firstNameInput.disabled = true; + if (lastNameInput) lastNameInput.disabled = true; + if (emailInput) emailInput.disabled = true; + if (saveProfileBtn) saveProfileBtn.style.display = 'none'; + userEditDesc = document.querySelector('#currentUserInfoDisplay > p > strong') + if (userEditDesc) { + userEditDesc.setAttribute('data-i18n', 'settings.current_user_oidc') + userEditDesc.textContent = 'OIDC managed profile for:' + } + securitySection = document.getElementById('securitySection'); + if (securitySection) securitySection.style.display = 'none'; + } // --- END UPDATE --- // Admin section visibility will be determined after API call @@ -3326,6 +3341,7 @@ async function loadSiteSettings() { const oidcClientSecretInputElem = document.getElementById('oidcClientSecret'); const oidcIssuerUrlInputElem = document.getElementById('oidcIssuerUrl'); const oidcScopeInputElem = document.getElementById('oidcScope'); + const oidcAdminGroupInputElem = document.getElementById('oidcAdminGroup'); try { showLoading(); @@ -3429,6 +3445,13 @@ async function loadSiteSettings() { } else { console.error('[OIDC Settings] oidcScopeInputElem element NOT FOUND locally.'); } + + if (oidcAdminGroupInputElem) { + console.log('[OIDC Settings] Found oidcAdminGroupInputElem. Setting value to:', settings.oidc_admin_group || ''); + oidcAdminGroupInputElem.value = settings.oidc_admin_group || ''; + } else { + console.error('[OIDC Settings] oidcAdminGroupInputElem element NOT FOUND locally.'); + } console.log('Site and OIDC settings loaded and population attempted using locally queried elements.'); @@ -3526,6 +3549,7 @@ async function saveOidcSettings() { oidc_client_id: oidcClientIdInput ? oidcClientIdInput.value.trim() : '', oidc_issuer_url: oidcIssuerUrlInput ? oidcIssuerUrlInput.value.trim() : '', oidc_scope: oidcScopeInput ? oidcScopeInput.value.trim() : 'openid email profile', + oidc_admin_group: oidcAdminGroupInput ? oidcAdminGroupInput.value.trim() : '' }; // Only include client_secret if a new value is entered diff --git a/frontend/status.html b/frontend/status.html index 767a950..29ed438 100644 --- a/frontend/status.html +++ b/frontend/status.html @@ -344,11 +344,12 @@
- - About + + About +
- Logout + Logout
diff --git a/frontend/style.css b/frontend/style.css index 24ea404..3d98d55 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -333,6 +333,7 @@ header .container { transition: color 0.3s ease; box-sizing: border-box; width: 25%; /* Ensure each tab is exactly 25% width */ + white-space: nowrap; } .form-tab:hover {