From 7241c4803f6d1086b9711d21784298c022f73b1f Mon Sep 17 00:00:00 2001 From: Peter Stockings Date: Thu, 20 Nov 2025 19:33:10 +1100 Subject: [PATCH] Initial setup for adding support for api key based auth --- app.py | 28 ++++- db.py | 38 ++++++- routes/auth.py | 7 +- routes/settings.py | 55 ++++++++++ templates/dashboard.html | 10 ++ templates/dashboard/settings/api_keys.html | 117 +++++++++++++++++++++ 6 files changed, 248 insertions(+), 7 deletions(-) create mode 100644 routes/settings.py create mode 100644 templates/dashboard/settings/api_keys.html diff --git a/app.py b/app.py index c8b82e4..4eaf860 100644 --- a/app.py +++ b/app.py @@ -16,6 +16,7 @@ from routes.home import home from routes.http import http from routes.llm import llm from routes.auth import auth +from routes.settings import settings from constants import DEFAULT_FUNCTION_NAME, DEFAULT_SCRIPT, DEFAULT_ENVIRONMENT from flask_apscheduler import APScheduler import asyncio @@ -43,6 +44,7 @@ app.register_blueprint(home, url_prefix='/home') app.register_blueprint(http, url_prefix='/http') app.register_blueprint(llm, url_prefix='/llm') 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 # 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 if not is_public: - if not current_user.is_authenticated: - return redirect(url_for('auth.login', next=request.url)) + is_authorized = False + + # 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 request_data = { diff --git a/db.py b/db.py index a3c032c..2d53687 100644 --- a/db.py +++ b/db.py @@ -134,4 +134,40 @@ ORDER BY invocation_time DESC""", [http_function_id]) def get_http_function_history(self, function_id): 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]) - return http_function_history \ No newline at end of file + 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 + ) \ No newline at end of file diff --git a/routes/auth.py b/routes/auth.py index 4065ada..79efd26 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -1,7 +1,8 @@ -from flask import Blueprint, render_template, request, redirect, url_for -from flask_login import login_user, logout_user, login_required, UserMixin +from flask import Blueprint, render_template, request, redirect, url_for, flash +from flask_login import login_user, logout_user, login_required, UserMixin, current_user 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__) diff --git a/routes/settings.py b/routes/settings.py new file mode 100644 index 0000000..74395c8 --- /dev/null +++ b/routes/settings.py @@ -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/", 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 diff --git a/templates/dashboard.html b/templates/dashboard.html index 7862a72..8d1db5f 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -81,6 +81,16 @@
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
+ {{ message }} +
+ {% endfor %} + {% endif %} + {% endwith %} {% block page %} {{ render_block(app.jinja_env, "dashboard/http_functions/overview.html", "page", http_functions=http_functions) }} diff --git a/templates/dashboard/settings/api_keys.html b/templates/dashboard/settings/api_keys.html new file mode 100644 index 0000000..ff13a6c --- /dev/null +++ b/templates/dashboard/settings/api_keys.html @@ -0,0 +1,117 @@ +{% extends 'dashboard.html' %} + +{% block page %} +
+
+

API Keys

+ +
+ +
+ + + + + + + + + + + + + {% for key in api_keys %} + + + + + + + + + {% else %} + + + + {% endfor %} + +
+ Name + Key Prefix + Scopes + Created + Last Used + Actions
{{ + key.name }} + {{ key.key[:8] }}... + + {% for scope in key.scopes %} + + {{ scope }} + + {% endfor %} + + {{ key.created_at.strftime('%Y-%m-%d') }} + + {{ key.last_used_at.strftime('%Y-%m-%d %H:%M') if key.last_used_at else 'Never' }} + + +
+ No API keys found. Generate one to get started. +
+
+
+ + + +
+

Generate New API Key

+
+
+ + +
+ +
+ +
+ + +
+

Currently only full access is supported via UI.

+
+ +
+ + +
+
+
+
+{% endblock %} \ No newline at end of file