mirror of
https://github.com/ChrispyBacon-dev/DockFlare.git
synced 2026-04-26 10:50:43 +00:00
oauth alpha - implementation
This commit is contained in:
parent
2d41b4ecf3
commit
295cfa634c
13 changed files with 894 additions and 27 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -82,6 +82,7 @@ $RECYCLE.BIN/
|
|||
*~
|
||||
.directory
|
||||
.env
|
||||
.docs
|
||||
data/state.json
|
||||
*.tmp
|
||||
*.bak
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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 %}
|
||||
|
|
|
|||
|
|
@ -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."})
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
6
package-lock.json
generated
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "DockFlare",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue