Initial setup for adding support for api key based auth

This commit is contained in:
Peter Stockings
2025-11-20 19:33:10 +11:00
parent dfcbd9263e
commit 7241c4803f
6 changed files with 248 additions and 7 deletions

28
app.py
View File

@@ -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))
# 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 int(current_user.id) != user_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 = {

38
db.py
View File

@@ -134,4 +134,40 @@ ORDER BY invocation_time DESC""", [http_function_id])
def get_http_function_history(self, function_id): def get_http_function_history(self, 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
)

View File

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

View File

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

View 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 %}