Merge pull request #138 from tecosaur/main

Mixed bag: Update compatability + depreceations + bugfixes + OIDC enhancements
This commit is contained in:
sassanix
2025-09-30 20:26:53 -03:00
committed by GitHub
18 changed files with 280 additions and 148 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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': '',

View File

@@ -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')

View File

@@ -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:

View File

@@ -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()

View File

@@ -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):

View File

@@ -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("""

View File

@@ -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)',

View File

@@ -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

View File

@@ -299,11 +299,12 @@
</a>
</div>
<div class="user-menu-item">
<i class="fas fa-info-circle"></i>
<a href="about.html" style="text-decoration: none; color: inherit;" data-i18n="nav.about">About</a>
<a href="about.html" style="text-decoration: none; color: inherit;">
<i class="fas fa-info-circle"></i> <span data-i18n="nav.about">About</span>
</a>
</div>
<div class="user-menu-item" id="logoutMenuItem">
<i class="fas fa-sign-out-alt"></i> <span data-i18n="auth.logout">Logout</span>
<span><i class="fas fa-sign-out-alt"></i> <span data-i18n="auth.logout">Logout</span></span>
</div>
</div>
</div>

View File

@@ -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;
}

View File

@@ -139,11 +139,12 @@
</a>
</div>
<div class="user-menu-item">
<i class="fas fa-info-circle"></i>
<a href="about.html" style="text-decoration: none; color: inherit;" data-i18n="nav.about">About</a>
<a href="about.html" style="text-decoration: none; color: inherit;">
<i class="fas fa-info-circle"></i> <span data-i18n="nav.about">About</span>
</a>
</div>
<div class="user-menu-item" id="logoutMenuItem">
<i class="fas fa-sign-out-alt"></i> <span data-i18n="auth.logout">Logout</span>
<span><i class="fas fa-sign-out-alt"></i> <span data-i18n="auth.logout">Logout</span></span>
</div>
</div>
</div>

View File

@@ -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;
}
}
}

View File

@@ -131,11 +131,12 @@
</a>
</div>
<div class="user-menu-item">
<i class="fas fa-info-circle"></i>
<a href="about.html" style="text-decoration: none; color: inherit;" data-i18n="nav.about">About</a>
<a href="about.html" style="text-decoration: none; color: inherit;">
<i class="fas fa-info-circle"></i> <span data-i18n="nav.about">About</span>
</a>
</div>
<div class="user-menu-item" id="logoutMenuItem">
<i class="fas fa-sign-out-alt"></i> <span data-i18n="auth.logout">Logout</span>
<span><i class="fas fa-sign-out-alt"></i> <span data-i18n="auth.logout">Logout</span></span>
</div>
</div>
</div>
@@ -414,7 +415,7 @@
</div>
<!-- Security -->
<div class="card">
<div id="securitySection" class="card">
<div class="card-header">
<h3 data-i18n="settings.security">Security</h3>
</div>
@@ -633,6 +634,12 @@
<input type="text" id="oidcScope" class="form-control" placeholder="e.g., openid email profile">
<small class="text-muted" data-i18n="settings.scope_desc">Space-separated OIDC scopes.</small>
</div>
<div class="form-group">
<label for="oidcAdminGroup" data-i18n="settings.oidc_admin_group">OIDC Admin Group</label>
<input type="text" id="oidcAdminGroup" class="form-control" />
<small class="text-muted" data-i18n="settings.oidc_admin_desc">Group that is given admin privileges. Leave blank to disable.</small>
</div>
<button type="button" id="saveOidcSettingsBtn" class="btn btn-primary" data-i18n="settings.save_oidc_settings">Save OIDC Settings</button>
<p id="oidcRestartMessage" class="text-muted" style="margin-top: 10px; display: none;" data-i18n="settings.oidc_restart_message">Application restart is required for OIDC settings to take full effect.</p>

View File

@@ -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

View File

@@ -344,11 +344,12 @@
</a>
</div>
<div class="user-menu-item">
<i class="fas fa-info-circle"></i>
<a href="about.html" style="text-decoration: none; color: inherit;" data-i18n="nav.about">About</a>
<a href="about.html" style="text-decoration: none; color: inherit;">
<i class="fas fa-info-circle"></i> <span data-i18n="nav.about">About</span>
</a>
</div>
<div class="user-menu-item" id="logoutMenuItem">
<i class="fas fa-sign-out-alt"></i> <span data-i18n="auth.logout">Logout</span>
<span><i class="fas fa-sign-out-alt"></i> <span data-i18n="auth.logout">Logout</span></span>
</div>
</div>
</div>

View File

@@ -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 {