oauth alpha - implementation

This commit is contained in:
ChrispyBacon-dev 2025-09-26 13:40:37 +02:00
parent 2d41b4ecf3
commit 295cfa634c
13 changed files with 894 additions and 27 deletions

1
.gitignore vendored
View file

@ -82,6 +82,7 @@ $RECYCLE.BIN/
*~
.directory
.env
.docs
data/state.json
*.tmp
*.bak

View file

@ -30,17 +30,18 @@ services:
restart: "no"
dockflare:
image: alplat/dockflare:stable
build: ./dockflare
#image: alplat/dockflare:stable
container_name: dockflare
restart: unless-stopped
ports:
- "5000:5000"
#labels:
- "5001:5000"
labels:
# -- Cloudflare Tunnel Configuration (via DockFlare) OPTIONAL --
#- dockflare.enable=true
#- dockflare.hostname=dockflare.domain.tld
#- dockflare.service=http://dockflare:5000
#- dockflare.access.policy=default_tld
- dockflare.enable=true
- dockflare.hostname=df.dataverse.icu
- dockflare.service=http://dockflare:5000
- dockflare.access.policy=bypass
volumes:
- dockflare_data:/app/data
environment:

View file

@ -24,6 +24,7 @@ import json
from flask import Flask
from flask_wtf.csrf import CSRFProtect
from flask_login import LoginManager
from authlib.integrations.flask_client import OAuth
from .core.user import User
import docker
from docker.errors import APIError
@ -37,6 +38,8 @@ log_queue = queue.Queue(maxsize=config.MAX_LOG_QUEUE_SIZE)
state_update_queue = queue.Queue(maxsize=50)
log_formatter = logging.Formatter('%(asctime)s [%(levelname)s] %(message)s', datefmt='%H:%M:%S')
oauth = None
class QueueLogHandler(logging.Handler):
def __init__(self, log_queue_instance):
super().__init__()
@ -96,6 +99,9 @@ def create_app():
app_instance.secret_key = os.urandom(24)
app_instance.config['PREFERRED_URL_SCHEME'] = 'http'
app_instance.config['APP_VERSION'] = config.APP_VERSION
app_instance.config['SESSION_COOKIE_HTTPONLY'] = True
app_instance.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
app_instance.config['PERMANENT_SESSION_LIFETIME'] = 86400
# Initialize CSRF Protection
csrf = CSRFProtect(app_instance)
@ -103,18 +109,27 @@ def create_app():
# Initialize Flask-Login
login_manager = LoginManager()
login_manager.init_app(app_instance)
login_manager.login_view = 'auth.login'
login_manager.login_view = 'web.login'
login_manager.login_message_category = "info"
# Initialize OAuth
global oauth
oauth = OAuth()
oauth.init_app(app_instance)
@login_manager.unauthorized_handler
def unauthorized():
"""Handle unauthorized access - return JSON for API requests, redirect for web requests."""
from flask import request, jsonify, redirect, url_for
# Check if this is an API request
if request.path.startswith('/api/'):
return jsonify({"status": "error", "message": "authentication_required"}), 401
# For web requests, redirect to login page
return redirect(url_for('auth.login'))
oauth_providers = app_instance.config.get('OAUTH_PROVIDERS', [])
if oauth_providers and not app_instance.config.get('DISABLE_PASSWORD_LOGIN', False):
return redirect(url_for('web.login'))
elif oauth_providers:
return redirect(url_for('web.login'))
else:
return redirect(url_for('auth.login'))
# Custom user loader that exempts API routes from authentication checks
@login_manager.request_loader
@ -129,13 +144,16 @@ def create_app():
@login_manager.user_loader
def load_user(user_id):
"""Load user from the config for session management."""
if not app_instance.is_configured:
return None
stored_username = app_instance.config.get('DOCKFLARE_USERNAME')
authorized_oauth_users = app_instance.config.get('OAUTH_AUTHORIZED_USERS', [])
if user_id == stored_username:
return User(user_id)
return User(user_id, auth_method='password')
elif user_id in authorized_oauth_users:
return User(user_id, auth_method='oauth')
return None
@app_instance.context_processor
@ -159,6 +177,7 @@ def create_app():
with app_instance.app_context():
from .web import routes as web_routes
app_instance.register_blueprint(web_routes.bp)
csrf.exempt(web_routes.auth_callback)
logging.info("Web blueprint registered.")
from .web.api_v2_routes import api_v2_bp

View file

@ -16,7 +16,23 @@
#
# dockflare/app/core/user.py
from flask_login import UserMixin
from datetime import datetime
from flask import current_app
class User(UserMixin):
def __init__(self, username):
def __init__(self, username, auth_method='password', session_data=None):
self.id = username
self.auth_method = auth_method
self.session_data = session_data or {}
self.login_time = datetime.utcnow()
@property
def is_oauth_user(self):
return self.auth_method == 'oauth'
def is_session_valid(self, max_age_seconds=None):
if max_age_seconds is None:
max_age_seconds = current_app.config.get('OAUTH_SESSION_TIMEOUT', 86400)
age = (datetime.utcnow() - self.login_time).total_seconds()
return age < max_age_seconds

View file

@ -231,6 +231,30 @@ def main_application_entrypoint():
f.write(updated_payload)
config_loader.apply_config_to_app(app, config_data)
from app import oauth
def register_oauth_providers(flask_app, oauth_instance):
providers = flask_app.config.get('OAUTH_PROVIDERS', [])
for provider in providers:
if not provider.get('enabled'):
continue
try:
client_id = fernet.decrypt(provider['client_id'].encode()).decode()
client_secret = fernet.decrypt(provider['client_secret'].encode()).decode()
except Exception:
logging.error(f"Could not decrypt credentials for provider {provider['name']}. Skipping.")
continue
oauth_instance.register(
name=provider['id'],
client_id=client_id,
client_secret=client_secret,
server_metadata_url=f"https://accounts.google.com/.well-known/openid-configuration",
client_kwargs={'scope': 'openid email profile'}
)
register_oauth_providers(app, oauth)
logging.info("DockFlare is configured and in Operational Mode.")
except Exception as e:
logging.error(f"Failed to load or decrypt configuration: {e}. Starting in Pre-Flight mode.", exc_info=True)

View file

@ -19,16 +19,50 @@
{% endfor %}
{% endif %}
{% endwith %}
{% if oauth_providers %}
<div class="flex flex-col w-full border-opacity-50">
{% for provider in oauth_providers %}
<a href="{{ url_for('web.login_provider', provider_id=provider.id) }}" class="btn btn-outline mb-2">
Login with {{ provider.name }}
</a>
{% endfor %}
{% if password_login_enabled %}
<div class="divider">OR</div>
{% endif %}
</div>
{% endif %}
{% if password_login_enabled %}
<form method="POST" action="{{ url_for('web.login') }}">
{% if config.DOCKFLARE_PASSWORD and config.SECRET_KEY %}{{ form.hidden_tag() }}{% endif %}
{% if form %}{{ form.hidden_tag() }}{% endif %}
<div class="form-control">
{{ form.password.label(class="label") }}
{{ form.password(class="input input-bordered", placeholder="password") }}
{% if form %}
{{ form.username.label(class="label") }}
{{ form.username(class="input input-bordered", placeholder="Username") }}
{% else %}
<label class="label">Username</label>
<input type="text" name="username" class="input input-bordered" placeholder="Username" required>
{% endif %}
</div>
<div class="form-control">
{% if form %}
{{ form.password.label(class="label") }}
{{ form.password(class="input input-bordered", placeholder="Password") }}
{% else %}
<label class="label">Password</label>
<input type="password" name="password" class="input input-bordered" placeholder="Password" required>
{% endif %}
</div>
<div class="form-control mt-6">
{{ form.submit(class="btn btn-primary") }}
{% if form %}
{{ form.submit(class="btn btn-primary") }}
{% else %}
<input type="submit" value="Login" class="btn btn-primary">
{% endif %}
</div>
</form>
{% endif %}
</div>
</div>
</body>

View file

@ -17,6 +17,7 @@
<li><a href="{{ url_for('web.settings_page') }}#cloudflare-tunnels">All Cloudflare Tunnels</a></li>
<li><a href="{{ url_for('web.settings_page') }}#backup-restore">Backup & Restore</a></li>
<li><a href="{{ url_for('web.settings_page') }}#security">Security</a></li>
<li><a href="{{ url_for('web.settings_page') }}#security" class="pl-8 text-sm">OAuth Authentication</a></li>
<li><a href="{{ url_for('web.settings_page') }}#tunnel-agent-status">Tunnel & Agent Status</a></li>
</ul>
<div class="mt-4 px-2">
@ -339,6 +340,77 @@
</div>
</div>
</div>
<!-- OAuth Authentication Section -->
<div class="mt-8">
<h3 class="font-semibold text-lg mb-4">OAuth Authentication</h3>
<p class="text-sm opacity-70 mb-4">Configure OAuth providers to allow users to log in with third-party services like Google, GitHub, or Microsoft.</p>
<!-- OAuth Settings Card -->
<div class="card bg-base-200 mb-6">
<div class="card-body">
<h4 class="card-title text-md">OAuth Settings</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label" for="oauth_session_timeout">
<span class="label-text">Session Timeout (seconds)</span>
</label>
{{ security_settings_form.oauth_session_timeout(class="input input-bordered input-sm", **{'id': 'oauth_session_timeout'}) }}
</div>
<div class="form-control">
<label class="label cursor-pointer p-0">
<span class="label-text">{{ security_settings_form.oauth_audit_enabled.label.text }}</span>
{{ security_settings_form.oauth_audit_enabled(class="toggle toggle-primary toggle-sm") }}
</label>
</div>
</div>
</div>
</div>
<!-- OAuth Providers Section -->
<div class="mb-6">
<div class="flex justify-between items-center mb-4">
<h4 class="text-md font-semibold">OAuth Providers</h4>
<button class="btn btn-primary btn-sm" onclick="showAddProviderModal()">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Add Provider
</button>
</div>
<div role="alert" class="alert alert-warning text-sm mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-5 w-5" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span><strong>Security:</strong> OAuth providers you configure here will be trusted to authenticate users. Only add providers you control or fully trust.</span>
</div>
<div id="provider-list" class="space-y-3">
<div class="text-center text-sm opacity-60 py-8">
No OAuth providers configured. Click "Add Provider" to get started.
</div>
</div>
</div>
<!-- Authorized Users Section -->
<div class="mb-6">
<div class="flex justify-between items-center mb-4">
<h4 class="text-md font-semibold">Authorized Users</h4>
<button class="btn btn-secondary btn-sm" onclick="showAddUserModal()">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
Add User
</button>
</div>
<p class="text-sm opacity-70 mb-4">Only users with email addresses listed here will be allowed to authenticate via OAuth.</p>
<div id="user-list" class="space-y-3">
<div class="text-center text-sm opacity-60 py-8">
No authorized users configured. Click "Add User" to authorize email addresses.
</div>
</div>
</div>
</div>
<div class="mt-8">
<h3 class="font-semibold text-lg mb-2">Password Reset</h3>
<div role="alert" class="alert alert-info text-sm">
@ -639,5 +711,342 @@
console.debug('Version check script error', e);
}
})();
(function() {
let authData = null;
async function loadAuthSettings() {
try {
const response = await fetch('/api/v2/auth/settings', {
headers: {
'Authorization': `Bearer ${window.masterApiKey || ''}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
authData = await response.json();
renderProviders();
renderUsers();
} else {
console.error('Failed to load auth settings');
}
} catch (error) {
console.error('Error loading auth settings:', error);
}
}
function renderProviders() {
const providerList = document.getElementById('provider-list');
if (!authData || !authData.providers || authData.providers.length === 0) {
providerList.innerHTML = '<div class="text-center text-sm opacity-60 py-8">No OAuth providers configured. Click "Add Provider" to get started.</div>';
return;
}
providerList.innerHTML = authData.providers.map(provider => `
<div class="card bg-base-100 border">
<div class="card-body p-4">
<div class="flex justify-between items-center">
<div class="flex items-center space-x-3">
<div class="badge badge-${provider.enabled ? 'success' : 'warning'}">${provider.enabled ? 'Enabled' : 'Disabled'}</div>
<div>
<h5 class="font-semibold">${escapeHtml(provider.name)}</h5>
<p class="text-sm opacity-70">${escapeHtml(provider.type)} · ID: ${escapeHtml(provider.id)}</p>
</div>
</div>
<div class="flex space-x-2">
<button class="btn btn-sm btn-outline" onclick="editProvider('${escapeHtml(provider.id)}')">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button class="btn btn-sm btn-error" onclick="deleteProvider('${escapeHtml(provider.id)}')">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
</div>
</div>
`).join('');
}
function renderUsers() {
const userList = document.getElementById('user-list');
if (!authData || !authData.users || authData.users.length === 0) {
userList.innerHTML = '<div class="text-center text-sm opacity-60 py-8">No authorized users configured. Click "Add User" to authorize email addresses.</div>';
return;
}
userList.innerHTML = authData.users.map(user => `
<div class="card bg-base-100 border">
<div class="card-body p-4">
<div class="flex justify-between items-center">
<div>
<h5 class="font-semibold">${escapeHtml(user.email)}</h5>
${user.name ? `<p class="text-sm opacity-70">${escapeHtml(user.name)}</p>` : ''}
${user.added_date ? `<p class="text-xs opacity-50">Added: ${new Date(user.added_date).toLocaleDateString()}</p>` : ''}
</div>
<button class="btn btn-sm btn-error" onclick="deleteUser('${escapeHtml(user.email)}')">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
</div>
`).join('');
}
function escapeHtml(unsafe) {
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
window.showAddProviderModal = function() {
document.getElementById('addProviderModal').showModal();
};
window.showAddUserModal = function() {
document.getElementById('addUserModal').showModal();
};
window.addProvider = async function() {
const form = document.getElementById('addProviderForm');
const formData = new FormData(form);
const providerData = {
id: formData.get('provider_id'),
name: formData.get('provider_name'),
type: formData.get('provider_type'),
client_id: formData.get('client_id'),
client_secret: formData.get('client_secret'),
enabled: formData.get('enabled') === 'on'
};
try {
const response = await fetch('/api/v2/auth/providers', {
method: 'POST',
headers: {
'Authorization': `Bearer ${window.masterApiKey || ''}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(providerData)
});
if (response.ok) {
document.getElementById('addProviderModal').close();
form.reset();
await loadAuthSettings();
showToast('Provider added successfully!', 'success');
} else {
const error = await response.json();
showToast(error.message || 'Failed to add provider', 'error');
}
} catch (error) {
showToast('Error adding provider', 'error');
}
};
window.addUser = async function() {
const form = document.getElementById('addUserForm');
const formData = new FormData(form);
const userData = {
email: formData.get('user_email'),
name: formData.get('user_name')
};
try {
const response = await fetch('/api/v2/auth/users', {
method: 'POST',
headers: {
'Authorization': `Bearer ${window.masterApiKey || ''}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(userData)
});
if (response.ok) {
document.getElementById('addUserModal').close();
form.reset();
await loadAuthSettings();
showToast('User added successfully!', 'success');
} else {
const error = await response.json();
showToast(error.message || 'Failed to add user', 'error');
}
} catch (error) {
showToast('Error adding user', 'error');
}
};
window.deleteProvider = async function(providerId) {
if (!confirm('Are you sure you want to delete this OAuth provider?')) return;
try {
const response = await fetch(`/api/v2/auth/providers/${providerId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${window.masterApiKey || ''}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
await loadAuthSettings();
showToast('Provider deleted successfully!', 'success');
} else {
showToast('Failed to delete provider', 'error');
}
} catch (error) {
showToast('Error deleting provider', 'error');
}
};
window.deleteUser = async function(userEmail) {
if (!confirm('Are you sure you want to remove this authorized user?')) return;
try {
const response = await fetch(`/api/v2/auth/users/${encodeURIComponent(userEmail)}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${window.masterApiKey || ''}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
await loadAuthSettings();
showToast('User removed successfully!', 'success');
} else {
showToast('Failed to remove user', 'error');
}
} catch (error) {
showToast('Error removing user', 'error');
}
};
function showToast(message, type) {
const toast = document.createElement('div');
toast.className = `toast toast-top toast-end`;
toast.innerHTML = `
<div class="alert alert-${type}">
<span>${message}</span>
</div>
`;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
}
document.addEventListener('DOMContentLoaded', () => {
if (window.masterApiKey) {
loadAuthSettings();
}
});
})();
</script>
<!-- Add Provider Modal -->
<dialog id="addProviderModal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg">Add OAuth Provider</h3>
<form id="addProviderForm" class="space-y-4 mt-4">
<div class="form-control">
<label class="label">
<span class="label-text">Provider Type</span>
</label>
<select name="provider_type" class="select select-bordered" required>
<option value="">Select Provider Type</option>
<option value="google">Google</option>
<option value="github">GitHub</option>
<option value="microsoft">Microsoft</option>
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Provider ID</span>
</label>
<input name="provider_id" type="text" placeholder="e.g. google, github-corp" class="input input-bordered" required />
<label class="label">
<span class="label-text-alt">Unique identifier for this provider</span>
</label>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Display Name</span>
</label>
<input name="provider_name" type="text" placeholder="e.g. Google, GitHub Corporate" class="input input-bordered" required />
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Client ID</span>
</label>
<input name="client_id" type="text" placeholder="OAuth Client ID" class="input input-bordered" required />
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Client Secret</span>
</label>
<input name="client_secret" type="password" placeholder="OAuth Client Secret" class="input input-bordered" required />
</div>
<div class="form-control">
<label class="label cursor-pointer">
<span class="label-text">Enable Provider</span>
<input name="enabled" type="checkbox" class="toggle" checked />
</label>
</div>
</form>
<div class="modal-action">
<form method="dialog">
<button class="btn">Cancel</button>
</form>
<button class="btn btn-primary" onclick="addProvider()">Add Provider</button>
</div>
</div>
</dialog>
<!-- Add User Modal -->
<dialog id="addUserModal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg">Add Authorized User</h3>
<form id="addUserForm" class="space-y-4 mt-4">
<div class="form-control">
<label class="label">
<span class="label-text">Email Address</span>
</label>
<input name="user_email" type="email" placeholder="user@example.com" class="input input-bordered" required />
<label class="label">
<span class="label-text-alt">This email must match exactly with the OAuth provider</span>
</label>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Display Name (Optional)</span>
</label>
<input name="user_name" type="text" placeholder="John Doe" class="input input-bordered" />
</div>
</form>
<div class="modal-action">
<form method="dialog">
<button class="btn">Cancel</button>
</form>
<button class="btn btn-primary" onclick="addUser()">Add User</button>
</div>
</div>
</dialog>
{% endblock %}

View file

@ -23,6 +23,7 @@ from datetime import datetime, timezone, timedelta
import secrets
import uuid
from flask import Blueprint, jsonify, request, current_app, url_for
from flask_login import login_required
from app import config, docker_client, tunnel_state, cloudflared_agent_state, publish_state_event
from app.core.state_manager import (
@ -1963,3 +1964,200 @@ def debug_info_api():
except Exception as e:
logging.error(f"Error in /api/v2/debug-info route: {e}", exc_info=True)
return jsonify({"status": "error", "message": "An internal error occurred."}), 500
def _save_encrypted_config(config_data, fernet_cipher):
try:
from app.web.config_loader import config_file_path
import json
encrypted_payload = fernet_cipher.encrypt(json.dumps(config_data).encode('utf-8'))
with open(config_file_path(), 'wb') as f:
f.write(encrypted_payload)
return True
except Exception as e:
logging.error(f"Failed to save encrypted config: {e}", exc_info=True)
return False
@api_v2_bp.route('/auth/settings', methods=['GET', 'PUT'])
@login_required
def manage_auth_settings():
from app.web.config_loader import load_encrypted_config_with_cipher
config_data, fernet = load_encrypted_config_with_cipher()
if config_data is None:
return jsonify({"error": "config_not_loaded"}), 500
if request.method == 'GET':
auth_settings = config_data.get('auth_settings', {})
providers = config_data.get('auth_providers', [])
users = config_data.get('authorized_users', [])
for p in providers:
p.pop('client_id', None)
p.pop('client_secret', None)
return jsonify({
"settings": auth_settings,
"providers": providers,
"users": users
})
if request.method == 'PUT':
data = request.get_json()
if 'auth_settings' in data:
config_data['auth_settings'] = data['auth_settings']
if 'oauth_settings' in data:
config_data['oauth_settings'] = data['oauth_settings']
if not _save_encrypted_config(config_data, fernet):
return jsonify({"error": "failed_to_save_config"}), 500
return jsonify({"status": "success", "message": "Settings saved. A restart may be required."})
@api_v2_bp.route('/auth/providers', methods=['GET', 'POST'])
@login_required
def manage_auth_providers():
from app.web.config_loader import load_encrypted_config_with_cipher
config_data, fernet = load_encrypted_config_with_cipher()
if config_data is None:
return jsonify({"error": "config_not_loaded"}), 500
if request.method == 'GET':
providers = config_data.get('auth_providers', [])
for p in providers:
p.pop('client_id', None)
p.pop('client_secret', None)
return jsonify({"providers": providers})
if request.method == 'POST':
data = request.get_json()
required_fields = ['id', 'name', 'type', 'client_id', 'client_secret']
if not all(field in data for field in required_fields):
return jsonify({"error": "missing_required_fields"}), 400
if not config_data.get('auth_providers'):
config_data['auth_providers'] = []
existing_ids = [p['id'] for p in config_data['auth_providers']]
if data['id'] in existing_ids:
return jsonify({"error": "provider_id_exists"}), 400
encrypted_client_id = fernet.encrypt(data['client_id'].encode()).decode()
encrypted_client_secret = fernet.encrypt(data['client_secret'].encode()).decode()
new_provider = {
'id': data['id'],
'name': data['name'],
'type': data['type'],
'client_id': encrypted_client_id,
'client_secret': encrypted_client_secret,
'enabled': data.get('enabled', True)
}
config_data['auth_providers'].append(new_provider)
if not _save_encrypted_config(config_data, fernet):
return jsonify({"error": "failed_to_save_config"}), 500
return jsonify({"status": "success", "message": "Provider added successfully."})
@api_v2_bp.route('/auth/providers/<provider_id>', methods=['PUT', 'DELETE'])
@login_required
def manage_auth_provider(provider_id):
from app.web.config_loader import load_encrypted_config_with_cipher
config_data, fernet = load_encrypted_config_with_cipher()
if config_data is None:
return jsonify({"error": "config_not_loaded"}), 500
providers = config_data.get('auth_providers', [])
provider_index = next((i for i, p in enumerate(providers) if p['id'] == provider_id), None)
if provider_index is None:
return jsonify({"error": "provider_not_found"}), 404
if request.method == 'PUT':
data = request.get_json()
provider = providers[provider_index]
if 'name' in data:
provider['name'] = data['name']
if 'enabled' in data:
provider['enabled'] = data['enabled']
if 'client_id' in data:
provider['client_id'] = fernet.encrypt(data['client_id'].encode()).decode()
if 'client_secret' in data:
provider['client_secret'] = fernet.encrypt(data['client_secret'].encode()).decode()
if not _save_encrypted_config(config_data, fernet):
return jsonify({"error": "failed_to_save_config"}), 500
return jsonify({"status": "success", "message": "Provider updated successfully."})
if request.method == 'DELETE':
del providers[provider_index]
if not _save_encrypted_config(config_data, fernet):
return jsonify({"error": "failed_to_save_config"}), 500
return jsonify({"status": "success", "message": "Provider deleted successfully."})
@api_v2_bp.route('/auth/users', methods=['GET', 'POST'])
@login_required
def manage_auth_users():
from app.web.config_loader import load_encrypted_config_with_cipher
from datetime import datetime
config_data, fernet = load_encrypted_config_with_cipher()
if config_data is None:
return jsonify({"error": "config_not_loaded"}), 500
if request.method == 'GET':
users = config_data.get('authorized_users', [])
return jsonify({"users": users})
if request.method == 'POST':
data = request.get_json()
if 'email' not in data:
return jsonify({"error": "email_required"}), 400
if not config_data.get('authorized_users'):
config_data['authorized_users'] = []
existing_emails = [u['email'] for u in config_data['authorized_users']]
if data['email'] in existing_emails:
return jsonify({"error": "user_exists"}), 400
new_user = {
'email': data['email'],
'name': data.get('name', ''),
'added_date': datetime.utcnow().isoformat()
}
config_data['authorized_users'].append(new_user)
if not _save_encrypted_config(config_data, fernet):
return jsonify({"error": "failed_to_save_config"}), 500
return jsonify({"status": "success", "message": "User added successfully."})
@api_v2_bp.route('/auth/users/<user_email>', methods=['DELETE'])
@login_required
def manage_auth_user(user_email):
from app.web.config_loader import load_encrypted_config_with_cipher
config_data, fernet = load_encrypted_config_with_cipher()
if config_data is None:
return jsonify({"error": "config_not_loaded"}), 500
users = config_data.get('authorized_users', [])
user_index = next((i for i, u in enumerate(users) if u['email'] == user_email), None)
if user_index is None:
return jsonify({"error": "user_not_found"}), 404
del users[user_index]
if not _save_encrypted_config(config_data, fernet):
return jsonify({"error": "failed_to_save_config"}), 500
return jsonify({"status": "success", "message": "User deleted successfully."})

View file

@ -92,6 +92,20 @@ def apply_config_to_app(flask_app, config_data: Dict) -> None:
flask_app.config['DISABLE_PASSWORD_LOGIN'] = config_data.get('disable_password_login', False)
flask_app.config['MASTER_API_KEY'] = effective_master_key
auth_settings = config_data.get('auth_settings', {})
password_login_enabled = auth_settings.get('password_login_enabled', True)
flask_app.config['DISABLE_PASSWORD_LOGIN'] = not password_login_enabled
flask_app.config['OAUTH_PROVIDERS'] = config_data.get('auth_providers', [])
flask_app.config['OAUTH_AUTHORIZED_USERS'] = [
user['email'] for user in config_data.get('authorized_users', [])
]
flask_app.config['OAUTH_AUDIT_ENABLED'] = config_data.get('oauth_audit_enabled', True)
oauth_settings = config_data.get('oauth_settings', {})
flask_app.config['OAUTH_SESSION_TIMEOUT'] = oauth_settings.get('session_timeout', 86400)
flask_app.config['OAUTH_MAX_LOGIN_ATTEMPTS'] = oauth_settings.get('max_login_attempts', 5)
config.CF_API_TOKEN = flask_app.config['CF_API_TOKEN']
config.CF_ACCOUNT_ID = flask_app.config['CF_ACCOUNT_ID']
config.CF_ZONE_ID = flask_app.config['CF_ZONE_ID']

View file

@ -45,6 +45,15 @@ class SecuritySettingsForm(FlaskForm):
disable_password_login = BooleanField(
'Disable Password Login'
)
oauth_session_timeout = IntegerField(
'OAuth Session Timeout (seconds)',
default=86400,
validators=[Optional()]
)
oauth_audit_enabled = BooleanField(
'Enable OAuth Audit Logging',
default=True
)
submit_security_settings = SubmitField('Save Security Settings')
class ChangePasswordForm(FlaskForm):
@ -70,6 +79,18 @@ class ChangePasswordForm(FlaskForm):
submit = SubmitField('Change Password')
class LoginForm(FlaskForm):
"""Form for the main login page."""
username = StringField(
'Username',
validators=[DataRequired(message="Username is required.")]
)
password = PasswordField(
'Password',
validators=[DataRequired(message="Password is required.")]
)
submit = SubmitField('Login')
class CloudflareCredentialsForm(FlaskForm):
"""Form for updating Cloudflare API credentials."""
cf_account_id = StringField(

View file

@ -31,7 +31,7 @@ from flask import (
Blueprint, render_template, jsonify, redirect, url_for, request, Response,
current_app, session, flash
)
from flask_login import current_user, login_required, login_user
from flask_login import current_user, login_required, login_user, logout_user
from app.core.user import User
from app import config, docker_client, tunnel_state, cloudflared_agent_state, log_queue, state_update_queue, publish_state_event
@ -169,17 +169,20 @@ def gating_logic():
if hasattr(current_app, 'login_manager'):
if current_app.config.get('DISABLE_PASSWORD_LOGIN'):
if not current_user.is_authenticated:
oauth_providers = current_app.config.get('OAUTH_PROVIDERS', [])
if oauth_providers and not current_user.is_authenticated:
return redirect(url_for('web.login'))
elif not oauth_providers and not current_user.is_authenticated:
login_user(User("anonymous"))
return
if not current_user.is_authenticated:
exempt_endpoints = ['static', 'web.ping', 'web.cloudflare_ping_route', 'setup.step_import_env']
if request.endpoint and not request.endpoint.startswith('auth.') and request.endpoint not in exempt_endpoints:
oauth_endpoints = ['web.login_provider', 'web.auth_callback', 'web.login']
if request.endpoint and not request.endpoint.startswith('auth.') and request.endpoint not in exempt_endpoints and request.endpoint not in oauth_endpoints:
try:
return redirect(url_for('auth.login'))
return redirect(url_for('web.login'))
except Exception:
pass
@bp.before_app_request
@ -221,12 +224,14 @@ def add_security_headers_bp(response):
@bp.context_processor
def inject_protocol_bp():
preferred_scheme = current_app.config.get('PREFERRED_URL_SCHEME', 'http')
base_url = f"{preferred_scheme}://{request.host}"
master_key_value = None
oauth_enabled = bool(current_app.config.get('OAUTH_PROVIDERS', []))
if current_user.is_authenticated:
master_key_value = current_app.config.get('MASTER_API_KEY')
return {
'protocol': preferred_scheme,
'is_https': preferred_scheme == 'https',
@ -234,7 +239,9 @@ def inject_protocol_bp():
'host': request.host,
'request_scheme': request.scheme,
'app_version': config.APP_VERSION,
'master_api_key': master_key_value
'master_api_key': master_key_value,
'oauth_enabled': oauth_enabled,
'current_user_auth_method': getattr(current_user, 'auth_method', None) if current_user.is_authenticated else None
}
@bp.route('/')
@ -1809,3 +1816,118 @@ def restore_state_backup():
cloudflared_agent_state["last_action_status"] = "Error: Restore failed. The file may be corrupt or invalid. Check logs."
return redirect(url_for('web.settings_page'))
@bp.route('/login', methods=['GET', 'POST'])
def login():
if current_user.is_authenticated:
return redirect(url_for('web.status_page'))
from .forms import LoginForm
form = LoginForm()
password_login_enabled = not current_app.config.get('DISABLE_PASSWORD_LOGIN', False)
if password_login_enabled and form.validate_on_submit():
username = form.username.data
password = form.password.data
stored_username = current_app.config.get('DOCKFLARE_USERNAME')
stored_hash = current_app.config.get('DOCKFLARE_PASSWORD_HASH')
from werkzeug.security import check_password_hash
if (username == stored_username and stored_hash and
check_password_hash(stored_hash, password)):
user = User(stored_username, auth_method='password')
login_user(user)
next_page = request.args.get('next')
if next_page and is_safe_url(next_page):
return redirect(next_page)
return redirect(url_for('web.status_page'))
else:
flash('Invalid username or password.', 'error')
oauth_providers = [
p for p in current_app.config.get('OAUTH_PROVIDERS', []) if p.get('enabled')
]
return render_template(
'login.html',
title="Login",
form=form,
password_login_enabled=password_login_enabled,
oauth_providers=oauth_providers
)
@bp.route('/login/<provider_id>')
def login_provider(provider_id):
import secrets
state_token = secrets.token_urlsafe(32)
session['oauth_state'] = state_token
from app import oauth
callback_url = url_for('web.auth_callback', provider_id=provider_id, _external=True)
return oauth.create_client(provider_id).authorize_redirect(callback_url, state=state_token)
@bp.route('/auth/<provider_id>/callback')
def auth_callback(provider_id):
received_state = request.args.get('state')
expected_state = session.pop('oauth_state', None)
if not received_state or not expected_state or received_state != expected_state:
flash('Invalid authentication state. Please try again.', 'error')
return redirect(url_for('web.login'))
from app import oauth
client = oauth.create_client(provider_id)
try:
token = client.authorize_access_token()
userinfo = client.userinfo()
except Exception as e:
logging.error(f"OAuth callback error for provider {provider_id}: {e}", exc_info=True)
flash('Authentication failed.', 'error')
return redirect(url_for('web.login'))
user_email = userinfo.get('email')
if not user_email:
flash('Could not retrieve email from provider. Cannot log in.', 'error')
return redirect(url_for('web.login'))
authorized_emails = current_app.config.get('OAUTH_AUTHORIZED_USERS', [])
if user_email not in authorized_emails:
flash(f'Access denied for user {user_email}.', 'error')
return redirect(url_for('web.login'))
user = User(user_email, auth_method='oauth')
login_user(user)
logging.info(f"OAUTH_SUCCESS: User {user_email} authenticated via {provider_id} from {request.remote_addr}")
next_page = request.args.get('next')
if next_page and is_safe_url(next_page):
return redirect(next_page)
return redirect(url_for('web.status_page'))
@bp.route('/logout')
@login_required
def logout():
auth_method = getattr(current_user, 'auth_method', 'password')
logout_user()
if auth_method == 'oauth':
flash('You have been logged out of DockFlare. You may still be logged into your OAuth provider.', 'info')
else:
flash('You have been logged out.', 'success')
if current_app.config.get('DISABLE_PASSWORD_LOGIN'):
oauth_providers = current_app.config.get('OAUTH_PROVIDERS', [])
if oauth_providers:
return redirect(url_for('web.login'))
else:
return redirect(url_for('web.status_page'))
return redirect(url_for('web.login'))
def is_safe_url(target):
from urllib.parse import urlparse, urljoin
from flask import request
ref_url = urlparse(request.host_url)
test_url = urlparse(urljoin(request.host_url, target))
return test_url.scheme in ('http', 'https') and ref_url.netloc == test_url.netloc

View file

@ -24,6 +24,8 @@ flask-login==0.6.3
# via -r dockflare/requirements.in
flask-wtf==1.2.2
# via -r dockflare/requirements.in
Authlib==1.3.0
# via OAuth2 integration
idna==3.10
# via requests
itsdangerous==2.2.0

6
package-lock.json generated Normal file
View file

@ -0,0 +1,6 @@
{
"name": "DockFlare",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}