flowforge-io 1.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flowforge/__init__.py +1 -0
- flowforge/api/__init__.py +0 -0
- flowforge/api/app.py +233 -0
- flowforge/api/auth.py +153 -0
- flowforge/api/routes/__init__.py +0 -0
- flowforge/api/routes/ai.py +310 -0
- flowforge/api/routes/audit.py +114 -0
- flowforge/api/routes/auth.py +56 -0
- flowforge/api/routes/bulk_loads.py +118 -0
- flowforge/api/routes/connections.py +230 -0
- flowforge/api/routes/emails.py +171 -0
- flowforge/api/routes/metrics.py +60 -0
- flowforge/api/routes/mfa.py +204 -0
- flowforge/api/routes/password_reset.py +150 -0
- flowforge/api/routes/pipelines.py +722 -0
- flowforge/api/routes/projects.py +125 -0
- flowforge/api/routes/providers.py +118 -0
- flowforge/api/routes/recipients.py +95 -0
- flowforge/api/routes/reports.py +148 -0
- flowforge/api/routes/runs.py +361 -0
- flowforge/api/routes/setup.py +93 -0
- flowforge/api/routes/sso.py +294 -0
- flowforge/api/routes/steps.py +116 -0
- flowforge/api/routes/users.py +194 -0
- flowforge/api/serializers.py +41 -0
- flowforge/api/validators.py +73 -0
- flowforge/audit.py +226 -0
- flowforge/celery_app.py +65 -0
- flowforge/cli.py +477 -0
- flowforge/connections/__init__.py +0 -0
- flowforge/connections/base.py +34 -0
- flowforge/connections/factory.py +64 -0
- flowforge/connections/mssql.py +105 -0
- flowforge/connections/mysql.py +92 -0
- flowforge/connections/odbc.py +96 -0
- flowforge/connections/oracle.py +101 -0
- flowforge/connections/postgres.py +76 -0
- flowforge/connections/ssh.py +128 -0
- flowforge/crypto.py +85 -0
- flowforge/db/__init__.py +0 -0
- flowforge/db/migrations/__init__.py +0 -0
- flowforge/db/migrations/env.py +71 -0
- flowforge/db/migrations/versions/0001_baseline.py +213 -0
- flowforge/db/migrations/versions/0002_timezone_timestamps.py +50 -0
- flowforge/db/migrations/versions/0003_indexes_and_constraints.py +53 -0
- flowforge/db/migrations/versions/0004_json_report_format.py +32 -0
- flowforge/db/migrations/versions/0005_bulk_load_configs.py +60 -0
- flowforge/db/migrations/versions/0006_projects.py +70 -0
- flowforge/db/migrations/versions/0007_pipeline_variables_index.py +29 -0
- flowforge/db/migrations/versions/0008_token_blocklist.py +30 -0
- flowforge/db/migrations/versions/0009_narrow_db_type_constraint.py +38 -0
- flowforge/db/migrations/versions/0010_webhook_tokens.py +37 -0
- flowforge/db/migrations/versions/0011_fix_step_type_constraint.py +37 -0
- flowforge/db/migrations/versions/0012_onedrive_support.py +47 -0
- flowforge/db/migrations/versions/0013_ai_analyze_step.py +41 -0
- flowforge/db/migrations/versions/0014_sftp_transfer_step.py +41 -0
- flowforge/db/migrations/versions/0015_datetime_timezone.py +69 -0
- flowforge/db/migrations/versions/0016_pipeline_failure_webhook.py +26 -0
- flowforge/db/migrations/versions/0017_mysql_connection_type.py +32 -0
- flowforge/db/migrations/versions/0018_user_role.py +32 -0
- flowforge/db/migrations/versions/0019_backfill_admin_role.py +33 -0
- flowforge/db/migrations/versions/0020_mfa_sso.py +38 -0
- flowforge/db/migrations/versions/0021_mssql_odbc_pwreset.py +55 -0
- flowforge/db/migrations/versions/0023_pipeline_deps_parallel.py +57 -0
- flowforge/db/migrations/versions/0024_report_column_formatting.py +33 -0
- flowforge/db/migrations/versions/0025_new_providers_notification.py +57 -0
- flowforge/db/migrations/versions/6158f44dafca_add_auditlog_model.py +44 -0
- flowforge/db/migrations/versions/9c08f36f9ef8_add_send_only_on_failure_to_ff_pipelines.py +28 -0
- flowforge/db/migrations/versions/__init__.py +0 -0
- flowforge/db/migrations/versions/b7a76582c1ea_add_sshconnection_and_update_step_type_.py +57 -0
- flowforge/db/migrations/versions/c4e8f2a1b9d3_add_ssh_health_check_step_type.py +44 -0
- flowforge/db/models.py +389 -0
- flowforge/email_providers/__init__.py +0 -0
- flowforge/email_providers/base.py +27 -0
- flowforge/email_providers/factory.py +92 -0
- flowforge/email_providers/gmail.py +96 -0
- flowforge/email_providers/mailgun.py +85 -0
- flowforge/email_providers/microsoft365.py +103 -0
- flowforge/email_providers/sendgrid.py +82 -0
- flowforge/email_providers/ses.py +103 -0
- flowforge/email_providers/smtp.py +93 -0
- flowforge/engine/__init__.py +0 -0
- flowforge/engine/cleanup.py +57 -0
- flowforge/engine/context.py +229 -0
- flowforge/engine/launcher.py +97 -0
- flowforge/engine/loader.py +83 -0
- flowforge/engine/runner.py +511 -0
- flowforge/engine/scheduler.py +244 -0
- flowforge/engine/shutdown.py +155 -0
- flowforge/reports/__init__.py +0 -0
- flowforge/reports/csv_report.py +24 -0
- flowforge/reports/excel_report.py +127 -0
- flowforge/reports/health_report.py +75 -0
- flowforge/reports/json_report.py +20 -0
- flowforge/reports/pdf_report.py +73 -0
- flowforge/steps/__init__.py +0 -0
- flowforge/steps/ai_analyze.py +188 -0
- flowforge/steps/base.py +54 -0
- flowforge/steps/bulk_load.py +528 -0
- flowforge/steps/data_load.py +305 -0
- flowforge/steps/db_health_check.py +211 -0
- flowforge/steps/db_procedure.py +50 -0
- flowforge/steps/db_query.py +133 -0
- flowforge/steps/drive_upload.py +34 -0
- flowforge/steps/email_step.py +222 -0
- flowforge/steps/notification.py +136 -0
- flowforge/steps/onedrive_upload.py +35 -0
- flowforge/steps/report.py +118 -0
- flowforge/steps/script_report.py +125 -0
- flowforge/steps/sftp_transfer.py +367 -0
- flowforge/steps/ssh_command.py +113 -0
- flowforge/steps/ssh_health_check.py +170 -0
- flowforge/storage/__init__.py +0 -0
- flowforge/storage/google_drive.py +90 -0
- flowforge/storage/onedrive.py +137 -0
- flowforge/tasks.py +44 -0
- flowforge_io-1.1.0.dist-info/METADATA +502 -0
- flowforge_io-1.1.0.dist-info/RECORD +122 -0
- flowforge_io-1.1.0.dist-info/WHEEL +5 -0
- flowforge_io-1.1.0.dist-info/entry_points.txt +2 -0
- flowforge_io-1.1.0.dist-info/licenses/LICENSE +21 -0
- flowforge_io-1.1.0.dist-info/top_level.txt +1 -0
flowforge/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = '0.1.0'
|
|
File without changes
|
flowforge/api/app.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Flask application factory."""
|
|
2
|
+
import ipaddress
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
from datetime import UTC
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from flask import Flask, jsonify, request, send_from_directory
|
|
9
|
+
from flask_cors import CORS
|
|
10
|
+
from flask_limiter import Limiter
|
|
11
|
+
from flask_limiter.util import get_remote_address
|
|
12
|
+
from werkzeug.middleware.proxy_fix import ProxyFix
|
|
13
|
+
|
|
14
|
+
from flowforge.db.models import db
|
|
15
|
+
|
|
16
|
+
limiter = Limiter(key_func=get_remote_address, default_limits=[])
|
|
17
|
+
|
|
18
|
+
# ── constants ──
|
|
19
|
+
_NOT_FOUND = 'Not found'
|
|
20
|
+
|
|
21
|
+
_DIST = Path(__file__).parent.parent.parent / 'frontend' / 'dist'
|
|
22
|
+
_DOCS = Path(__file__).parent.parent.parent / 'docs'
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def create_app(config: dict | None = None) -> Flask:
|
|
26
|
+
# Configure root logger from LOG_LEVEL env var — no-op if CLI already called basicConfig.
|
|
27
|
+
_level = getattr(logging, os.environ.get('LOG_LEVEL', 'INFO').upper(), logging.INFO)
|
|
28
|
+
logging.basicConfig(level=_level, format='%(asctime)s %(levelname)s %(name)s — %(message)s')
|
|
29
|
+
|
|
30
|
+
app = Flask(__name__)
|
|
31
|
+
|
|
32
|
+
app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get(
|
|
33
|
+
'FLOWFORGE_DB_URL', 'postgresql://flowforge:flowforge@localhost:5432/flowforge' # NOSONAR
|
|
34
|
+
)
|
|
35
|
+
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
|
36
|
+
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16 MB — prevents OOM on large POST bodies
|
|
37
|
+
|
|
38
|
+
# Connection pool tuning — tune via env vars for multi-worker Gunicorn deployments.
|
|
39
|
+
# Rule of thumb: POOL_SIZE × gunicorn_workers ≤ max_connections on PostgreSQL side.
|
|
40
|
+
app.config['SQLALCHEMY_POOL_SIZE'] = int(os.environ.get('SQLALCHEMY_POOL_SIZE', '5'))
|
|
41
|
+
app.config['SQLALCHEMY_MAX_OVERFLOW'] = int(os.environ.get('SQLALCHEMY_MAX_OVERFLOW', '10'))
|
|
42
|
+
app.config['SQLALCHEMY_POOL_TIMEOUT'] = int(os.environ.get('SQLALCHEMY_POOL_TIMEOUT', '30'))
|
|
43
|
+
app.config['SQLALCHEMY_POOL_RECYCLE'] = int(os.environ.get('SQLALCHEMY_POOL_RECYCLE', '1800'))
|
|
44
|
+
|
|
45
|
+
# AES-256 encryption key — used exclusively by flowforge/crypto.py
|
|
46
|
+
app.config['SECRET_KEY'] = os.environ.get('FLOWFORGE_SECRET_KEY', '') # NOSONAR
|
|
47
|
+
|
|
48
|
+
# JWT signing secret — separate from the encryption key (SEC-2)
|
|
49
|
+
# Falls back to SECRET_KEY for backward compatibility; set FLOWFORGE_JWT_SECRET in production.
|
|
50
|
+
jwt_secret = os.environ.get('FLOWFORGE_JWT_SECRET', '')
|
|
51
|
+
if not jwt_secret:
|
|
52
|
+
jwt_secret = app.config['SECRET_KEY']
|
|
53
|
+
if jwt_secret and not (config or {}).get('TESTING'):
|
|
54
|
+
import warnings
|
|
55
|
+
warnings.warn(
|
|
56
|
+
'FLOWFORGE_JWT_SECRET is not set — falling back to FLOWFORGE_SECRET_KEY for JWT '
|
|
57
|
+
'signing. Set a separate FLOWFORGE_JWT_SECRET in production.',
|
|
58
|
+
stacklevel=2,
|
|
59
|
+
)
|
|
60
|
+
app.config['JWT_SECRET'] = jwt_secret
|
|
61
|
+
app.config['JWT_ALGORITHM'] = 'HS256'
|
|
62
|
+
app.config['JWT_EXPIRY_HOURS'] = 24
|
|
63
|
+
|
|
64
|
+
if config:
|
|
65
|
+
app.config.update(config)
|
|
66
|
+
|
|
67
|
+
# ProxyFix — unwraps X-Forwarded-For so the rate limiter sees the real client IP (SEC-3)
|
|
68
|
+
# Set FLOWFORGE_TRUSTED_PROXIES=1 when running behind nginx/Traefik/ALB/etc.
|
|
69
|
+
num_proxies = int(os.environ.get('FLOWFORGE_TRUSTED_PROXIES', '0'))
|
|
70
|
+
if num_proxies > 0:
|
|
71
|
+
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=num_proxies, x_proto=num_proxies, x_host=num_proxies)
|
|
72
|
+
|
|
73
|
+
db.init_app(app)
|
|
74
|
+
limiter.init_app(app)
|
|
75
|
+
|
|
76
|
+
if os.environ.get('FLOWFORGE_REDIS_URL'):
|
|
77
|
+
from flowforge.celery_app import init_celery
|
|
78
|
+
init_celery(app)
|
|
79
|
+
|
|
80
|
+
# CORS — warn loudly if FLOWFORGE_CORS_ORIGIN is not set in production (SEC-6)
|
|
81
|
+
cors_origin = os.environ.get('FLOWFORGE_CORS_ORIGIN', '')
|
|
82
|
+
if not cors_origin:
|
|
83
|
+
flask_env = os.environ.get('FLASK_ENV', 'development')
|
|
84
|
+
is_testing = (config or {}).get('TESTING', False)
|
|
85
|
+
if flask_env == 'production' and not is_testing:
|
|
86
|
+
app.logger.warning(
|
|
87
|
+
'SECURITY: FLOWFORGE_CORS_ORIGIN is not set. '
|
|
88
|
+
'All cross-origin browser requests to /api/* will be blocked. '
|
|
89
|
+
'Set FLOWFORGE_CORS_ORIGIN=https://your-domain.com in production.'
|
|
90
|
+
)
|
|
91
|
+
cors_origin = 'http://localhost:5173' # dev/test fallback only
|
|
92
|
+
CORS(app, resources={r'/api/*': {'origins': cors_origin}})
|
|
93
|
+
|
|
94
|
+
_register_blueprints(app)
|
|
95
|
+
_register_error_handlers(app)
|
|
96
|
+
_register_ip_allowlist(app)
|
|
97
|
+
|
|
98
|
+
with app.app_context():
|
|
99
|
+
_sweep_stuck_runs(app)
|
|
100
|
+
|
|
101
|
+
return app
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _register_ip_allowlist(app: Flask) -> None:
|
|
105
|
+
"""If FLOWFORGE_ALLOWED_IPS is set, reject /api/* requests from non-listed IPs."""
|
|
106
|
+
raw = os.environ.get('FLOWFORGE_ALLOWED_IPS', '').strip()
|
|
107
|
+
if not raw:
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
networks: list[ipaddress.IPv4Network | ipaddress.IPv6Network] = []
|
|
111
|
+
for cidr in raw.split(','):
|
|
112
|
+
cidr = cidr.strip()
|
|
113
|
+
if not cidr:
|
|
114
|
+
continue
|
|
115
|
+
try:
|
|
116
|
+
networks.append(ipaddress.ip_network(cidr, strict=False))
|
|
117
|
+
except ValueError:
|
|
118
|
+
app.logger.warning('FLOWFORGE_ALLOWED_IPS: invalid CIDR %r — skipped', cidr)
|
|
119
|
+
|
|
120
|
+
if not networks:
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
@app.before_request
|
|
124
|
+
def _check_ip():
|
|
125
|
+
if not request.path.startswith('/api/'):
|
|
126
|
+
return None
|
|
127
|
+
client_ip = request.remote_addr or ''
|
|
128
|
+
try:
|
|
129
|
+
addr = ipaddress.ip_address(client_ip)
|
|
130
|
+
if not any(addr in net for net in networks):
|
|
131
|
+
return jsonify({'error': 'Access denied: your IP is not allowed'}), 403
|
|
132
|
+
except ValueError:
|
|
133
|
+
return jsonify({'error': 'Access denied: invalid client IP'}), 403
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _sweep_stuck_runs(app: Flask) -> None:
|
|
138
|
+
"""Mark any pipeline runs left in 'running' state as failed (interrupted by restart)."""
|
|
139
|
+
try:
|
|
140
|
+
from datetime import datetime
|
|
141
|
+
|
|
142
|
+
from flowforge.db.models import PipelineRun
|
|
143
|
+
stuck = db.session.query(PipelineRun).filter_by(status='running').all()
|
|
144
|
+
if not stuck:
|
|
145
|
+
return
|
|
146
|
+
for run in stuck:
|
|
147
|
+
run.status = 'failed'
|
|
148
|
+
run.error_message = 'Run interrupted by server restart'
|
|
149
|
+
if not run.finished_at:
|
|
150
|
+
run.finished_at = datetime.now(UTC)
|
|
151
|
+
db.session.commit()
|
|
152
|
+
app.logger.warning('Swept %d stuck pipeline run(s) left from previous session.', len(stuck))
|
|
153
|
+
except Exception:
|
|
154
|
+
pass # Migrations not yet applied or DB unreachable — skip silently
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _register_blueprints(app: Flask) -> None:
|
|
158
|
+
from flowforge.api.routes.ai import bp as ai_bp
|
|
159
|
+
from flowforge.api.routes.audit import bp as audit_bp
|
|
160
|
+
from flowforge.api.routes.auth import bp as auth_bp
|
|
161
|
+
from flowforge.api.routes.bulk_loads import bp as bulk_loads_bp
|
|
162
|
+
from flowforge.api.routes.connections import bp as connections_bp
|
|
163
|
+
from flowforge.api.routes.emails import bp as emails_bp
|
|
164
|
+
from flowforge.api.routes.metrics import bp as metrics_bp
|
|
165
|
+
from flowforge.api.routes.mfa import bp as mfa_bp
|
|
166
|
+
from flowforge.api.routes.password_reset import bp as password_reset_bp
|
|
167
|
+
from flowforge.api.routes.pipelines import bp as pipelines_bp
|
|
168
|
+
from flowforge.api.routes.projects import bp as projects_bp
|
|
169
|
+
from flowforge.api.routes.providers import bp as providers_bp
|
|
170
|
+
from flowforge.api.routes.recipients import bp as recipients_bp
|
|
171
|
+
from flowforge.api.routes.reports import bp as reports_bp
|
|
172
|
+
from flowforge.api.routes.runs import bp as runs_bp
|
|
173
|
+
from flowforge.api.routes.setup import bp as setup_bp
|
|
174
|
+
from flowforge.api.routes.sso import bp as sso_bp
|
|
175
|
+
from flowforge.api.routes.steps import bp as steps_bp
|
|
176
|
+
from flowforge.api.routes.users import bp as users_bp
|
|
177
|
+
|
|
178
|
+
for blueprint in (
|
|
179
|
+
ai_bp, audit_bp, auth_bp, bulk_loads_bp, connections_bp, emails_bp, metrics_bp,
|
|
180
|
+
mfa_bp, password_reset_bp, pipelines_bp, projects_bp, providers_bp, recipients_bp,
|
|
181
|
+
reports_bp, runs_bp, setup_bp, sso_bp, steps_bp, users_bp,
|
|
182
|
+
):
|
|
183
|
+
app.register_blueprint(blueprint, url_prefix='/api')
|
|
184
|
+
|
|
185
|
+
@app.get('/api/health')
|
|
186
|
+
def health():
|
|
187
|
+
return jsonify({'status': 'ok'})
|
|
188
|
+
|
|
189
|
+
@app.get('/api/docs/<path:filename>')
|
|
190
|
+
def serve_doc(filename):
|
|
191
|
+
if not _DOCS.is_dir():
|
|
192
|
+
return jsonify({'error': 'Docs not found'}), 404
|
|
193
|
+
file_path = (_DOCS / filename).resolve()
|
|
194
|
+
if not str(file_path).startswith(str(_DOCS.resolve())):
|
|
195
|
+
return jsonify({'error': _NOT_FOUND}), 404
|
|
196
|
+
if not file_path.is_file():
|
|
197
|
+
return jsonify({'error': _NOT_FOUND}), 404
|
|
198
|
+
return send_from_directory(_DOCS, filename, mimetype='text/plain; charset=utf-8')
|
|
199
|
+
|
|
200
|
+
if _DIST.is_dir():
|
|
201
|
+
@app.get('/', defaults={'path': ''})
|
|
202
|
+
@app.get('/<path:path>')
|
|
203
|
+
def serve_spa(path):
|
|
204
|
+
file_path = _DIST / path
|
|
205
|
+
if path and file_path.is_file():
|
|
206
|
+
return send_from_directory(_DIST, path)
|
|
207
|
+
return send_from_directory(_DIST, 'index.html')
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _register_error_handlers(app: Flask) -> None:
|
|
211
|
+
@app.errorhandler(429)
|
|
212
|
+
def rate_limited(e):
|
|
213
|
+
return jsonify({'error': 'Too many login attempts. Try again in a minute.'}), 429
|
|
214
|
+
|
|
215
|
+
@app.errorhandler(400)
|
|
216
|
+
def bad_request(e):
|
|
217
|
+
return jsonify({'error': str(e)}), 400
|
|
218
|
+
|
|
219
|
+
@app.errorhandler(401)
|
|
220
|
+
def unauthorized(e):
|
|
221
|
+
return jsonify({'error': 'Unauthorized'}), 401
|
|
222
|
+
|
|
223
|
+
@app.errorhandler(403)
|
|
224
|
+
def forbidden(e):
|
|
225
|
+
return jsonify({'error': 'Forbidden'}), 403
|
|
226
|
+
|
|
227
|
+
@app.errorhandler(404)
|
|
228
|
+
def not_found(e):
|
|
229
|
+
return jsonify({'error': _NOT_FOUND}), 404
|
|
230
|
+
|
|
231
|
+
@app.errorhandler(500)
|
|
232
|
+
def server_error(e):
|
|
233
|
+
return jsonify({'error': 'Internal server error'}), 500
|
flowforge/api/auth.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""JWT authentication — single-user v1 / multi-user v2 with optional MFA."""
|
|
2
|
+
import uuid as _uuid_mod
|
|
3
|
+
from datetime import UTC, datetime, timedelta
|
|
4
|
+
from functools import wraps
|
|
5
|
+
|
|
6
|
+
import bcrypt
|
|
7
|
+
import jwt
|
|
8
|
+
from flask import current_app, g, jsonify, request
|
|
9
|
+
|
|
10
|
+
from flowforge.db.models import TokenBlocklist, User, db
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _algorithm() -> str:
|
|
14
|
+
return current_app.config.get('JWT_ALGORITHM', 'HS256')
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _secret() -> str:
|
|
18
|
+
# SEC-2: use JWT_SECRET (separate from the AES encryption key)
|
|
19
|
+
return current_app.config.get('JWT_SECRET') or current_app.config['SECRET_KEY']
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _expiry_hours() -> int:
|
|
23
|
+
return current_app.config.get('JWT_EXPIRY_HOURS', 24)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def generate_token(user: User) -> str:
|
|
27
|
+
payload = {
|
|
28
|
+
'sub': user.username,
|
|
29
|
+
'uid': user.id,
|
|
30
|
+
'role': user.role,
|
|
31
|
+
'jti': str(_uuid_mod.uuid4()),
|
|
32
|
+
'iat': datetime.now(UTC),
|
|
33
|
+
'exp': datetime.now(UTC) + timedelta(hours=_expiry_hours()),
|
|
34
|
+
}
|
|
35
|
+
return jwt.encode(payload, _secret(), algorithm=_algorithm())
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def generate_mfa_token(user: User) -> str:
|
|
39
|
+
"""Short-lived (5 min) challenge token issued when password is correct but MFA pending."""
|
|
40
|
+
payload = {
|
|
41
|
+
'sub': user.username,
|
|
42
|
+
'uid': user.id,
|
|
43
|
+
'role': user.role,
|
|
44
|
+
'jti': str(_uuid_mod.uuid4()),
|
|
45
|
+
'mfa_step': True,
|
|
46
|
+
'iat': datetime.now(UTC),
|
|
47
|
+
'exp': datetime.now(UTC) + timedelta(minutes=5),
|
|
48
|
+
}
|
|
49
|
+
return jwt.encode(payload, _secret(), algorithm=_algorithm())
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def verify_mfa_token(token: str) -> dict | None:
|
|
53
|
+
"""Verify a MFA challenge token. Returns payload if valid and un-revoked, else None."""
|
|
54
|
+
try:
|
|
55
|
+
payload = jwt.decode(token, _secret(), algorithms=[_algorithm()])
|
|
56
|
+
if not payload.get('mfa_step'):
|
|
57
|
+
return None
|
|
58
|
+
jti = payload.get('jti')
|
|
59
|
+
if jti and db.session.get(TokenBlocklist, jti) is not None:
|
|
60
|
+
return None
|
|
61
|
+
return payload
|
|
62
|
+
except jwt.PyJWTError:
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def verify_token(token: str) -> dict | None:
|
|
67
|
+
"""Return the payload if the token is valid and not revoked, else None."""
|
|
68
|
+
try:
|
|
69
|
+
payload = jwt.decode(token, _secret(), algorithms=[_algorithm()])
|
|
70
|
+
# Reject MFA challenge tokens from being used as full session tokens.
|
|
71
|
+
if payload.get('mfa_step'):
|
|
72
|
+
return None
|
|
73
|
+
jti = payload.get('jti')
|
|
74
|
+
if jti and db.session.get(TokenBlocklist, jti) is not None:
|
|
75
|
+
return None
|
|
76
|
+
return payload
|
|
77
|
+
except jwt.PyJWTError:
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def revoke_token(token: str) -> str | None:
|
|
82
|
+
"""Add the token's jti to the blocklist. Returns the username on success, None if invalid.
|
|
83
|
+
|
|
84
|
+
Tokens issued before jti was added (no jti claim) are not blocklisted — the client
|
|
85
|
+
must still clear its local storage; the token will expire naturally within 24h.
|
|
86
|
+
"""
|
|
87
|
+
try:
|
|
88
|
+
payload = jwt.decode(token, _secret(), algorithms=[_algorithm()])
|
|
89
|
+
except jwt.PyJWTError:
|
|
90
|
+
return None
|
|
91
|
+
jti = payload.get('jti')
|
|
92
|
+
exp = payload.get('exp')
|
|
93
|
+
if jti:
|
|
94
|
+
expires_at = datetime.fromtimestamp(exp, tz=UTC) if exp else datetime.now(UTC)
|
|
95
|
+
db.session.merge(TokenBlocklist(jti=jti, expires_at=expires_at))
|
|
96
|
+
db.session.commit()
|
|
97
|
+
return payload.get('sub')
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def login(username: str, password: str) -> 'str | dict | None':
|
|
101
|
+
"""Verify credentials.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
str — full JWT (MFA not enabled or not configured)
|
|
105
|
+
dict — {'mfa_required': True, 'mfa_token': '...'} when MFA is enabled
|
|
106
|
+
None — invalid credentials
|
|
107
|
+
"""
|
|
108
|
+
user = db.session.query(User).filter_by(username=username).first()
|
|
109
|
+
if not user:
|
|
110
|
+
return None
|
|
111
|
+
if not bcrypt.checkpw(password.encode(), user.password_hash.encode()):
|
|
112
|
+
return None
|
|
113
|
+
if user.mfa_enabled:
|
|
114
|
+
return {'mfa_required': True, 'mfa_token': generate_mfa_token(user)}
|
|
115
|
+
return generate_token(user)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def require_auth(f):
|
|
119
|
+
"""Decorator: require a valid Bearer JWT in the Authorization header.
|
|
120
|
+
Stores the user payload in flask.g.user_token.
|
|
121
|
+
"""
|
|
122
|
+
@wraps(f)
|
|
123
|
+
def wrapper(*args, **kwargs):
|
|
124
|
+
header = request.headers.get('Authorization', '')
|
|
125
|
+
if not header.startswith('Bearer '):
|
|
126
|
+
return jsonify({'error': 'Missing or invalid Authorization header'}), 401
|
|
127
|
+
token = header[len('Bearer '):]
|
|
128
|
+
payload = verify_token(token)
|
|
129
|
+
if not payload:
|
|
130
|
+
return jsonify({'error': 'Invalid or expired token'}), 401
|
|
131
|
+
g.user_token = payload
|
|
132
|
+
g.current_user_id = payload.get('uid')
|
|
133
|
+
return f(*args, **kwargs)
|
|
134
|
+
return wrapper
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def require_role(roles: str | list[str]):
|
|
138
|
+
"""Decorator: require the user to have one of the specified roles."""
|
|
139
|
+
if isinstance(roles, str):
|
|
140
|
+
roles = [roles]
|
|
141
|
+
|
|
142
|
+
def decorator(f):
|
|
143
|
+
@wraps(f)
|
|
144
|
+
@require_auth
|
|
145
|
+
def wrapper(*args, **kwargs):
|
|
146
|
+
user_role = g.user_token.get('role', 'viewer')
|
|
147
|
+
if 'admin' == user_role: # admins can do everything
|
|
148
|
+
return f(*args, **kwargs)
|
|
149
|
+
if user_role not in roles:
|
|
150
|
+
return jsonify({'error': f'Access denied: required role in {roles}'}), 403
|
|
151
|
+
return f(*args, **kwargs)
|
|
152
|
+
return wrapper
|
|
153
|
+
return decorator
|
|
File without changes
|