Initial setup for adding support for api key based auth
This commit is contained in:
28
app.py
28
app.py
@@ -16,6 +16,7 @@ from routes.home import home
|
|||||||
from routes.http import http
|
from routes.http import http
|
||||||
from routes.llm import llm
|
from routes.llm import llm
|
||||||
from routes.auth import auth
|
from routes.auth import auth
|
||||||
|
from routes.settings import settings
|
||||||
from constants import DEFAULT_FUNCTION_NAME, DEFAULT_SCRIPT, DEFAULT_ENVIRONMENT
|
from constants import DEFAULT_FUNCTION_NAME, DEFAULT_SCRIPT, DEFAULT_ENVIRONMENT
|
||||||
from flask_apscheduler import APScheduler
|
from flask_apscheduler import APScheduler
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -43,6 +44,7 @@ app.register_blueprint(home, url_prefix='/home')
|
|||||||
app.register_blueprint(http, url_prefix='/http')
|
app.register_blueprint(http, url_prefix='/http')
|
||||||
app.register_blueprint(llm, url_prefix='/llm')
|
app.register_blueprint(llm, url_prefix='/llm')
|
||||||
app.register_blueprint(auth, url_prefix='/auth')
|
app.register_blueprint(auth, url_prefix='/auth')
|
||||||
|
app.register_blueprint(settings, url_prefix='/settings')
|
||||||
|
|
||||||
# Swith to inter app routing, which results in speed up from ~400ms to ~270ms
|
# Swith to inter app routing, which results in speed up from ~400ms to ~270ms
|
||||||
# https://stackoverflow.com/questions/76886643/linking-two-not-exposed-dokku-apps
|
# https://stackoverflow.com/questions/76886643/linking-two-not-exposed-dokku-apps
|
||||||
@@ -150,10 +152,30 @@ async def execute_http_function(user_id, function):
|
|||||||
|
|
||||||
# Check if the function is public, if not check if the user is authenticated and owns the function
|
# Check if the function is public, if not check if the user is authenticated and owns the function
|
||||||
if not is_public:
|
if not is_public:
|
||||||
if not current_user.is_authenticated:
|
is_authorized = False
|
||||||
return redirect(url_for('auth.login', next=request.url))
|
|
||||||
|
|
||||||
if int(current_user.id) != user_id:
|
# 1. Session Authentication
|
||||||
|
if current_user.is_authenticated and int(current_user.id) == user_id:
|
||||||
|
is_authorized = True
|
||||||
|
|
||||||
|
# 2. API Key Authentication
|
||||||
|
elif 'X-API-Key' in request.headers:
|
||||||
|
api_key_value = request.headers.get('X-API-Key')
|
||||||
|
api_key = db.get_api_key(api_key_value)
|
||||||
|
|
||||||
|
if api_key and api_key['user_id'] == user_id:
|
||||||
|
# Check Scopes
|
||||||
|
scopes = api_key['scopes']
|
||||||
|
if isinstance(scopes, str):
|
||||||
|
scopes = json.loads(scopes)
|
||||||
|
|
||||||
|
if "*" in scopes or f"function:{http_function['id']}" in scopes:
|
||||||
|
is_authorized = True
|
||||||
|
db.update_api_key_last_used(api_key['id'])
|
||||||
|
|
||||||
|
if not is_authorized:
|
||||||
|
if not current_user.is_authenticated:
|
||||||
|
return redirect(url_for('auth.login', next=request.url))
|
||||||
return jsonify({'error': 'Function belongs to another user'}), 404
|
return jsonify({'error': 'Function belongs to another user'}), 404
|
||||||
|
|
||||||
request_data = {
|
request_data = {
|
||||||
|
|||||||
36
db.py
36
db.py
@@ -135,3 +135,39 @@ ORDER BY invocation_time DESC""", [http_function_id])
|
|||||||
http_function_history = self.execute(
|
http_function_history = self.execute(
|
||||||
'SELECT version_id, http_function_id, script_content, version_number, updated_at FROM http_functions_versions WHERE http_function_id=%s ORDER BY version_number DESC', [function_id])
|
'SELECT version_id, http_function_id, script_content, version_number, updated_at FROM http_functions_versions WHERE http_function_id=%s ORDER BY version_number DESC', [function_id])
|
||||||
return http_function_history
|
return http_function_history
|
||||||
|
|
||||||
|
def create_api_key(self, user_id, name, key, scopes):
|
||||||
|
self.execute(
|
||||||
|
'INSERT INTO api_keys (user_id, name, key, scopes) VALUES (%s, %s, %s, %s)',
|
||||||
|
[user_id, name, key, json.dumps(scopes)],
|
||||||
|
commit=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_api_key(self, key):
|
||||||
|
api_key = self.execute(
|
||||||
|
'SELECT id, user_id, name, key, scopes, created_at, last_used_at FROM api_keys WHERE key=%s',
|
||||||
|
[key],
|
||||||
|
one=True
|
||||||
|
)
|
||||||
|
return api_key
|
||||||
|
|
||||||
|
def delete_api_key(self, user_id, key_id):
|
||||||
|
self.execute(
|
||||||
|
'DELETE FROM api_keys WHERE user_id=%s AND id=%s',
|
||||||
|
[user_id, key_id],
|
||||||
|
commit=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def list_api_keys(self, user_id):
|
||||||
|
api_keys = self.execute(
|
||||||
|
'SELECT id, user_id, name, key, scopes, created_at, last_used_at FROM api_keys WHERE user_id=%s ORDER BY created_at DESC',
|
||||||
|
[user_id]
|
||||||
|
)
|
||||||
|
return api_keys
|
||||||
|
|
||||||
|
def update_api_key_last_used(self, key_id):
|
||||||
|
self.execute(
|
||||||
|
'UPDATE api_keys SET last_used_at=NOW() WHERE id=%s',
|
||||||
|
[key_id],
|
||||||
|
commit=True
|
||||||
|
)
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
from flask import Blueprint, render_template, request, redirect, url_for
|
from flask import Blueprint, render_template, request, redirect, url_for, flash
|
||||||
from flask_login import login_user, logout_user, login_required, UserMixin
|
from flask_login import login_user, logout_user, login_required, UserMixin, current_user
|
||||||
from werkzeug.security import generate_password_hash, check_password_hash
|
from werkzeug.security import generate_password_hash, check_password_hash
|
||||||
from extensions import db, login_manager
|
from extensions import db, login_manager, environment, htmx
|
||||||
|
from jinja2_fragments import render_block
|
||||||
|
|
||||||
auth = Blueprint('auth', __name__)
|
auth = Blueprint('auth', __name__)
|
||||||
|
|
||||||
|
|||||||
55
routes/settings.py
Normal file
55
routes/settings.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
from flask import Blueprint, render_template, request, redirect, url_for, flash
|
||||||
|
from flask_login import login_required, current_user
|
||||||
|
from extensions import db, environment, htmx
|
||||||
|
from jinja2_fragments import render_block
|
||||||
|
import secrets
|
||||||
|
import json
|
||||||
|
|
||||||
|
settings = Blueprint('settings', __name__)
|
||||||
|
|
||||||
|
@settings.route("/api-keys", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
def api_keys():
|
||||||
|
user_id = current_user.id
|
||||||
|
api_keys = db.list_api_keys(user_id)
|
||||||
|
|
||||||
|
# Parse scopes for display
|
||||||
|
for key in api_keys:
|
||||||
|
if isinstance(key['scopes'], str):
|
||||||
|
key['scopes'] = json.loads(key['scopes'])
|
||||||
|
|
||||||
|
if htmx:
|
||||||
|
return render_block(
|
||||||
|
environment,
|
||||||
|
"dashboard/settings/api_keys.html",
|
||||||
|
"page",
|
||||||
|
api_keys=api_keys
|
||||||
|
)
|
||||||
|
return render_template("dashboard/settings/api_keys.html", api_keys=api_keys)
|
||||||
|
|
||||||
|
@settings.route("/api-keys", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def create_api_key():
|
||||||
|
user_id = current_user.id
|
||||||
|
name = request.form.get("name", "My API Key")
|
||||||
|
scopes_list = request.form.getlist("scopes")
|
||||||
|
|
||||||
|
if not scopes_list:
|
||||||
|
scopes = ["*"]
|
||||||
|
else:
|
||||||
|
scopes = scopes_list
|
||||||
|
|
||||||
|
# Generate a secure random key
|
||||||
|
key = f"sk_{secrets.token_urlsafe(24)}"
|
||||||
|
|
||||||
|
db.create_api_key(user_id, name, key, scopes)
|
||||||
|
|
||||||
|
flash(f"API Key created: {key} - Save it now, you won't see it again!", "success")
|
||||||
|
return redirect(url_for("settings.api_keys"))
|
||||||
|
|
||||||
|
@settings.route("/api-keys/<int:key_id>", methods=["DELETE"])
|
||||||
|
@login_required
|
||||||
|
def delete_api_key(key_id):
|
||||||
|
user_id = current_user.id
|
||||||
|
db.delete_api_key(user_id, key_id)
|
||||||
|
return "", 200
|
||||||
@@ -81,6 +81,16 @@
|
|||||||
</a>
|
</a>
|
||||||
</header>
|
</header>
|
||||||
<main class="flex flex-1 flex-col gap-4 p-4 md:p-6" data-id="50" id="container">
|
<main class="flex flex-1 flex-col gap-4 p-4 md:p-6" data-id="50" id="container">
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div
|
||||||
|
class="mb-4 p-4 rounded-lg {% if category == 'success' %}bg-green-100 text-green-700{% else %}bg-red-100 text-red-700{% endif %}">
|
||||||
|
{{ message }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
{% block page %}
|
{% block page %}
|
||||||
{{ render_block(app.jinja_env, "dashboard/http_functions/overview.html", "page",
|
{{ render_block(app.jinja_env, "dashboard/http_functions/overview.html", "page",
|
||||||
http_functions=http_functions) }}
|
http_functions=http_functions) }}
|
||||||
|
|||||||
117
templates/dashboard/settings/api_keys.html
Normal file
117
templates/dashboard/settings/api_keys.html
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
{% extends 'dashboard.html' %}
|
||||||
|
|
||||||
|
{% block page %}
|
||||||
|
<div class="p-6 max-w-4xl mx-auto">
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">API Keys</h1>
|
||||||
|
<button onclick="document.getElementById('create-key-modal').showModal()"
|
||||||
|
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
|
||||||
|
Generate New Key
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||||
|
<tr>
|
||||||
|
<th
|
||||||
|
class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||||
|
Name</th>
|
||||||
|
<th
|
||||||
|
class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||||
|
Key Prefix</th>
|
||||||
|
<th
|
||||||
|
class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||||
|
Scopes</th>
|
||||||
|
<th
|
||||||
|
class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||||
|
Created</th>
|
||||||
|
<th
|
||||||
|
class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||||
|
Last Used</th>
|
||||||
|
<th
|
||||||
|
class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||||
|
Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{% for key in api_keys %}
|
||||||
|
<tr id="key-row-{{ key.id }}">
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-white">{{
|
||||||
|
key.name }}</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{ key.key[:8] }}...
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{% for scope in key.scopes %}
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 mr-1">
|
||||||
|
{{ scope }}
|
||||||
|
</span>
|
||||||
|
{% endfor %}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{ key.created_at.strftime('%Y-%m-%d') }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{ key.last_used_at.strftime('%Y-%m-%d %H:%M') if key.last_used_at else 'Never' }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
|
<button hx-delete="{{ url_for('settings.delete_api_key', key_id=key.id) }}"
|
||||||
|
hx-target="#key-row-{{ key.id }}" hx-swap="outerHTML"
|
||||||
|
hx-confirm="Are you sure you want to revoke this key?"
|
||||||
|
class="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300">
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="px-6 py-4 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
No API keys found. Generate one to get started.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Key Modal -->
|
||||||
|
<dialog id="create-key-modal" class="p-0 rounded-lg shadow-xl backdrop:bg-gray-900/50">
|
||||||
|
<div class="bg-white dark:bg-gray-800 w-full max-w-md p-6">
|
||||||
|
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-4">Generate New API Key</h3>
|
||||||
|
<form action="{{ url_for('settings.create_api_key') }}" method="POST">
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Name</label>
|
||||||
|
<input type="text" name="name" required placeholder="e.g., CI/CD Pipeline"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Scopes</label>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="flex items-center">
|
||||||
|
<input type="checkbox" name="scopes" value="*" checked
|
||||||
|
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
||||||
|
<span class="ml-2 text-sm text-gray-600 dark:text-gray-400">Full Access (*)</span>
|
||||||
|
</label>
|
||||||
|
<!-- Future: Add function specific scopes here -->
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">Currently only full access is supported via UI.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end space-x-3">
|
||||||
|
<button type="button" onclick="document.getElementById('create-key-modal').close()"
|
||||||
|
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-600">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit"
|
||||||
|
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||||
|
Generate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user