Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,19 @@ def create_app(config=None):
socketio.init_app(app, cors_allowed_origins="*")
oauth.init_app(app)

# Initialize Settings from environment variables on startup
# This ensures .env values are used as initial values, but WebUI changes take priority
with app.app_context():
try:
from app.models import Settings
# This will create Settings if it doesn't exist and initialize from .env
# The get_settings() method automatically initializes new Settings from .env
Settings.get_settings()
except Exception as e:
# Don't fail app startup if Settings initialization fails
# (e.g., database not ready yet, migration not run)
app.logger.warning(f"Could not initialize Settings from environment: {e}")

# Initialize Flask-Mail
from app.utils.email import init_mail
init_mail(app)
Expand Down
6 changes: 5 additions & 1 deletion app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,11 @@ class Config:
ALLOW_SELF_REGISTER = os.getenv('ALLOW_SELF_REGISTER', 'true').lower() == 'true'
ADMIN_USERNAMES = os.getenv('ADMIN_USERNAMES', 'admin').split(',')

# Authentication method: 'local' | 'oidc' | 'both'
# Authentication method: 'none' | 'local' | 'oidc' | 'both'
# 'none' = no password authentication (username only)
# 'local' = password authentication required
# 'oidc' = OIDC/Single Sign-On only
# 'both' = OIDC + local password authentication
AUTH_METHOD = os.getenv('AUTH_METHOD', 'local').strip().lower()

# OIDC settings (used when AUTH_METHOD is 'oidc' or 'both')
Expand Down
83 changes: 82 additions & 1 deletion app/models/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,11 @@ def to_dict(self):

@classmethod
def get_settings(cls):
"""Get the singleton settings instance, creating it if it doesn't exist"""
"""Get the singleton settings instance, creating it if it doesn't exist.

When creating a new Settings instance, it will be initialized from
environment variables (.env file) as initial values.
"""
try:
settings = cls.query.first()
if settings:
Expand All @@ -283,7 +287,10 @@ def get_settings(cls):
# initialization code or explicit admin flows.
try:
if not getattr(db.session, "_flushing", False):
# Create new settings instance initialized from environment variables
settings = cls()
# Initialize from environment variables (.env file)
cls._initialize_from_env(settings)
db.session.add(settings)
db.session.commit()
return settings
Expand Down Expand Up @@ -311,3 +318,77 @@ def update_settings(cls, **kwargs):
settings.updated_at = datetime.utcnow()
db.session.commit()
return settings

@classmethod
def _initialize_from_env(cls, settings_instance):
"""
Initialize Settings instance from environment variables (.env file).
This is called when creating a new Settings instance to use .env values
as initial startup values.

Args:
settings_instance: Settings instance to initialize
"""
# Map environment variable names to Settings model attributes
env_mapping = {
'TZ': 'timezone',
'CURRENCY': 'currency',
'ROUNDING_MINUTES': 'rounding_minutes',
'SINGLE_ACTIVE_TIMER': 'single_active_timer',
'ALLOW_SELF_REGISTER': 'allow_self_register',
'IDLE_TIMEOUT_MINUTES': 'idle_timeout_minutes',
'BACKUP_RETENTION_DAYS': 'backup_retention_days',
'BACKUP_TIME': 'backup_time',
}

for env_var, attr_name in env_mapping.items():
if hasattr(settings_instance, attr_name):
env_value = os.getenv(env_var)
if env_value is not None:
# Convert value types based on attribute type
current_value = getattr(settings_instance, attr_name)

if isinstance(current_value, bool):
# Handle boolean values
setattr(settings_instance, attr_name, env_value.lower() == 'true')
elif isinstance(current_value, int):
# Handle integer values
try:
setattr(settings_instance, attr_name, int(env_value))
except (ValueError, TypeError):
pass # Keep default if conversion fails
else:
# Handle string values
setattr(settings_instance, attr_name, env_value)

@classmethod
def sync_from_env(cls):
"""
Sync Settings from environment variables (.env file) for fields that haven't
been customized in the WebUI. This is useful for initializing Settings on startup
or when new environment variables are added.

Only updates fields that are still at their default values (not customized via WebUI).
"""
try:
settings = cls.get_settings()
if not settings or not hasattr(settings, 'id'):
# Settings doesn't exist in DB yet, get_settings will create it
return

# Only sync if Settings was just created (id is None means it's a new instance)
# For existing Settings, we don't overwrite WebUI changes
# This method is mainly for ensuring new Settings get initialized from .env
if settings.id is None:
cls._initialize_from_env(settings)
if hasattr(db.session, 'add'):
db.session.add(settings)
db.session.commit()
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.warning(f"Could not sync Settings from environment: {e}")
try:
db.session.rollback()
except Exception:
pass
26 changes: 21 additions & 5 deletions app/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ class User(UserMixin, db.Model):
oidc_sub = db.Column(db.String(255), nullable=True)
oidc_issuer = db.Column(db.String(255), nullable=True)
avatar_filename = db.Column(db.String(255), nullable=True)
password_hash = db.Column(db.String(255), nullable=True)

# User preferences and settings
email_notifications = db.Column(db.Boolean, default=True, nullable=False) # Enable/disable email notifications
Expand Down Expand Up @@ -70,12 +71,27 @@ def __repr__(self):

def set_password(self, password):
"""
Stub method for test compatibility.
This application uses username-only authentication (or OIDC),
so passwords are not actually used or stored.
Set the user's password hash.
For OIDC users, password is optional.
"""
# No-op: this application doesn't use password authentication
pass
if password:
self.password_hash = generate_password_hash(password)
else:
self.password_hash = None

def check_password(self, password):
"""
Check if the provided password matches the user's password hash.
Returns False if no password is set or if password doesn't match.
"""
if not self.password_hash or not password:
return False
return check_password_hash(self.password_hash, password)

@property
def has_password(self):
"""Check if user has a password set"""
return bool(self.password_hash)

@property
def is_admin(self):
Expand Down
100 changes: 83 additions & 17 deletions app/routes/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,25 +41,29 @@ def login():
if current_user.is_authenticated:
return redirect(url_for('main.dashboard'))

# If OIDC-only mode, redirect to OIDC login start
# Get authentication method
try:
auth_method = (getattr(Config, 'AUTH_METHOD', 'local') or 'local').strip().lower()
except Exception:
auth_method = 'local'

# Determine if password authentication is required
requires_password = auth_method in ('local', 'both')

# If OIDC-only mode, redirect to OIDC login start
if auth_method == 'oidc':
# In OIDC-only mode, do not allow local form login at all
return redirect(url_for('auth.login_oidc', next=request.args.get('next')))

if request.method == 'POST':
try:
username = request.form.get('username', '').strip().lower()
current_app.logger.info("POST /login (username=%s) from %s", username or '<empty>', request.headers.get('X-Forwarded-For') or request.remote_addr)
password = request.form.get('password', '')
current_app.logger.info("POST /login (username=%s, auth_method=%s) from %s", username or '<empty>', auth_method, request.headers.get('X-Forwarded-For') or request.remote_addr)

if not username:
log_event("auth.login_failed", reason="empty_username", auth_method="local")
log_event("auth.login_failed", reason="empty_username", auth_method=auth_method)
flash(_('Username is required'), 'error')
return render_template('auth/login.html', allow_self_register=Config.ALLOW_SELF_REGISTER, auth_method=auth_method)
return render_template('auth/login.html', allow_self_register=Config.ALLOW_SELF_REGISTER, auth_method=auth_method, requires_password=requires_password)

# Normalize admin usernames from config
try:
Expand All @@ -74,51 +78,86 @@ def login():
if not user:
# Check if self-registration is allowed
if Config.ALLOW_SELF_REGISTER:
# If password auth is required, validate password during self-registration
if requires_password:
if not password:
flash(_('Password is required to create an account.'), 'error')
return render_template('auth/login.html', allow_self_register=Config.ALLOW_SELF_REGISTER, auth_method=auth_method, requires_password=requires_password)
if len(password) < 8:
flash(_('Password must be at least 8 characters long.'), 'error')
return render_template('auth/login.html', allow_self_register=Config.ALLOW_SELF_REGISTER, auth_method=auth_method, requires_password=requires_password)

# Create new user, promote to admin if username is configured as admin
role = 'admin' if username in admin_usernames else 'user'
user = User(username=username, role=role)
# Set password if password auth is required
if requires_password and password:
user.set_password(password)
db.session.add(user)
if not safe_commit('self_register_user', {'username': username}):
current_app.logger.error("Self-registration failed for '%s' due to DB error", username)
flash(_('Could not create your account due to a database error. Please try again later.'), 'error')
return render_template('auth/login.html', allow_self_register=Config.ALLOW_SELF_REGISTER, auth_method=auth_method)
return render_template('auth/login.html', allow_self_register=Config.ALLOW_SELF_REGISTER, auth_method=auth_method, requires_password=requires_password)
current_app.logger.info("Created new user '%s'", username)

# Track onboarding started for new user
track_onboarding_started(user.id, {
"auth_method": "local",
"auth_method": auth_method,
"self_registered": True,
"is_admin": role == 'admin'
})

flash(_('Welcome! Your account has been created.'), 'success')
else:
log_event("auth.login_failed", username=username, reason="user_not_found", auth_method="local")
log_event("auth.login_failed", username=username, reason="user_not_found", auth_method=auth_method)
flash(_('User not found. Please contact an administrator.'), 'error')
return render_template('auth/login.html', allow_self_register=Config.ALLOW_SELF_REGISTER, auth_method=auth_method)
return render_template('auth/login.html', allow_self_register=Config.ALLOW_SELF_REGISTER, auth_method=auth_method, requires_password=requires_password)
else:
# If existing user matches admin usernames, ensure admin role
if username in admin_usernames and user.role != 'admin':
user.role = 'admin'
if not safe_commit('promote_admin_user', {'username': username}):
current_app.logger.error("Failed to promote '%s' to admin due to DB error", username)
flash(_('Could not update your account role due to a database error.'), 'error')
return render_template('auth/login.html', allow_self_register=Config.ALLOW_SELF_REGISTER, auth_method=auth_method)
return render_template('auth/login.html', allow_self_register=Config.ALLOW_SELF_REGISTER, auth_method=auth_method, requires_password=requires_password)

# Check if user is active
if not user.is_active:
log_event("auth.login_failed", user_id=user.id, reason="account_disabled", auth_method="local")
log_event("auth.login_failed", user_id=user.id, reason="account_disabled", auth_method=auth_method)
flash(_('Account is disabled. Please contact an administrator.'), 'error')
return render_template('auth/login.html', allow_self_register=Config.ALLOW_SELF_REGISTER, auth_method=auth_method)
return render_template('auth/login.html', allow_self_register=Config.ALLOW_SELF_REGISTER, auth_method=auth_method, requires_password=requires_password)

# Handle password authentication based on mode
if requires_password:
# Password authentication is required
if user.has_password:
# User has password set - verify it
if not password:
log_event("auth.login_failed", user_id=user.id, reason="password_required", auth_method=auth_method)
flash(_('Password is required'), 'error')
return render_template('auth/login.html', allow_self_register=Config.ALLOW_SELF_REGISTER, auth_method=auth_method, requires_password=requires_password)

if not user.check_password(password):
log_event("auth.login_failed", user_id=user.id, reason="invalid_password", auth_method=auth_method)
flash(_('Invalid username or password'), 'error')
return render_template('auth/login.html', allow_self_register=Config.ALLOW_SELF_REGISTER, auth_method=auth_method, requires_password=requires_password)
else:
# User doesn't have password set - prompt to set one
log_event("auth.login_failed", user_id=user.id, reason="no_password_set", auth_method=auth_method)
flash(_('No password is set for your account. Please set a password in your profile to continue.'), 'error')
# Still log them in so they can set password in profile
login_user(user, remember=True)
return redirect(url_for('auth.edit_profile'))

# For 'none' mode, no password check needed - just log in
# Log in the user
login_user(user, remember=True)
user.update_last_login()
current_app.logger.info("User '%s' logged in successfully", user.username)

# Track successful login
log_event("auth.login", user_id=user.id, auth_method="local")
track_event(user.id, "auth.login", {"auth_method": "local"})
log_event("auth.login", user_id=user.id, auth_method=auth_method)
track_event(user.id, "auth.login", {"auth_method": auth_method})

# Identify user with comprehensive segmentation properties
identify_user_with_segments(user.id, user)
Expand All @@ -137,9 +176,9 @@ def login():
except Exception as e:
current_app.logger.exception("Login error: %s", e)
flash(_('Unexpected error during login. Please try again or check server logs.'), 'error')
return render_template('auth/login.html', allow_self_register=Config.ALLOW_SELF_REGISTER, auth_method=auth_method)
return render_template('auth/login.html', allow_self_register=Config.ALLOW_SELF_REGISTER, auth_method=auth_method, requires_password=requires_password)

return render_template('auth/login.html', allow_self_register=Config.ALLOW_SELF_REGISTER, auth_method=auth_method)
return render_template('auth/login.html', allow_self_register=Config.ALLOW_SELF_REGISTER, auth_method=auth_method, requires_password=requires_password)

@auth_bp.route('/logout')
@login_required
Expand Down Expand Up @@ -200,6 +239,14 @@ def profile():
@login_required
def edit_profile():
"""Edit user profile"""
# Get authentication method to determine if password fields should be shown
try:
auth_method = (getattr(Config, 'AUTH_METHOD', 'local') or 'local').strip().lower()
except Exception:
auth_method = 'local'

requires_password = auth_method in ('local', 'both')

if request.method == 'POST':
# Update real name if provided
full_name = request.form.get('full_name', '').strip()
Expand All @@ -211,6 +258,25 @@ def edit_profile():
current_user.preferred_language = preferred_language
# Also set session so it applies immediately
session['preferred_language'] = preferred_language

# Handle password update if password auth is required
if requires_password:
password = request.form.get('password', '').strip()
password_confirm = request.form.get('password_confirm', '').strip()

if password:
# Validate password
if len(password) < 8:
flash(_('Password must be at least 8 characters long.'), 'error')
return redirect(url_for('auth.edit_profile'))

if password != password_confirm:
flash(_('Passwords do not match.'), 'error')
return redirect(url_for('auth.edit_profile'))

# Set the new password
current_user.set_password(password)
current_app.logger.info("User '%s' updated password", current_user.username)

# Handle avatar upload if provided
try:
Expand Down Expand Up @@ -269,7 +335,7 @@ def edit_profile():
flash(_('Could not update your profile due to a database error.'), 'error')
return redirect(url_for('auth.profile'))

return render_template('auth/edit_profile.html')
return render_template('auth/edit_profile.html', requires_password=requires_password)


@auth_bp.route('/profile/avatar/remove', methods=['POST'])
Expand Down
Loading
Loading