From 6913689e3f94d4f54bcb7e3c4ff6a2a154266743 Mon Sep 17 00:00:00 2001 From: Jakub Man Date: Fri, 3 Nov 2023 10:12:00 +0100 Subject: [PATCH 01/26] Support for authentication using external proxy (#33) * add options for HTTP header authentication to config * add template for handling error 401: Unauthorized * support external authentication Expects authentication to be done using an external tool (such as Apache), that fills the users UUID to a HTTP header and acts as a proxy. --- config.example.py | 6 +++++ flowapp/__init__.py | 47 ++++++++++++++++++++++----------- flowapp/auth.py | 11 +++++++- flowapp/templates/errors/401.j2 | 7 +++++ 4 files changed, 55 insertions(+), 16 deletions(-) create mode 100755 flowapp/templates/errors/401.j2 diff --git a/config.example.py b/config.example.py index e539040e..956ba963 100644 --- a/config.example.py +++ b/config.example.py @@ -9,6 +9,12 @@ class Config(): TESTING = False # SSO auth enabled SSO_AUTH = False + # Authentication is done outside the app, use HTTP header to get the user uuid. + # If SSO_AUTH is set to True, this option is ignored and SSO auth is used. + HEADER_AUTH = True + # Name of HTTP header containing the UUID of authenticated user. + # Only used when HEADER_AUTH is set to True + AUTH_HEADER_NAME = 'X-Authenticated-User' # SSO LOGOUT LOGOUT_URL = "https://flowspec.example.com/Shibboleth.sso/Logout" # SQL Alchemy config diff --git a/flowapp/__init__.py b/flowapp/__init__.py index a29297a7..83433e06 100644 --- a/flowapp/__init__.py +++ b/flowapp/__init__.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- import babel -from flask import Flask, redirect, render_template, session, url_for +from flask import Flask, redirect, render_template, session, url_for, request from flask_sso import SSO from flask_sqlalchemy import SQLAlchemy from flask_wtf.csrf import CSRFProtect @@ -72,21 +72,9 @@ def login(user_info): else: user = db.session.query(models.User).filter_by(uuid=uuid).first() try: - session["user_uuid"] = user.uuid - session["user_email"] = user.uuid - session["user_name"] = user.name - session["user_id"] = user.id - session["user_roles"] = [role.name for role in user.role.all()] - session["user_orgs"] = ", ".join( - org.name for org in user.organization.all() - ) - session["user_role_ids"] = [role.id for role in user.role.all()] - session["user_org_ids"] = [org.id for org in user.organization.all()] - roles = [i > 1 for i in session["user_role_ids"]] - session["can_edit"] = True if all(roles) and roles else [] + _register_user_to_session(uuid) except AttributeError: - return redirect("/") - + pass return redirect("/") @app.route("/logout") @@ -96,6 +84,19 @@ def logout(): session.clear() return redirect(app.config.get("LOGOUT_URL")) + @app.route("/ext-login") + def ext_login(): + header_name = app.config.get("AUTH_HEADER_NAME", 'X-Authenticated-User') + if header_name not in request.headers: + return render_template("errors/401.j2") + uuid = request.headers.get(header_name) + if uuid: + try: + _register_user_to_session(uuid) + except AttributeError: + return render_template("errors/401.j2") + return redirect("/") + @app.route("/") @auth_required def index(): @@ -177,4 +178,20 @@ def format_datetime(value): return babel.dates.format_datetime(value, format) + def _register_user_to_session(uuid: str): + user = db.session.query(models.User).filter_by(uuid=uuid).first() + session["user_uuid"] = user.uuid + session["user_email"] = user.uuid + session["user_name"] = user.name + session["user_id"] = user.id + session["user_roles"] = [role.name for role in user.role.all()] + session["user_orgs"] = ", ".join( + org.name for org in user.organization.all() + ) + session["user_role_ids"] = [role.id for role in user.role.all()] + session["user_org_ids"] = [org.id for org in user.organization.all()] + roles = [i > 1 for i in session["user_role_ids"]] + session["can_edit"] = True if all(roles) and roles else [] + return app + diff --git a/flowapp/auth.py b/flowapp/auth.py index b4925d99..c4d942ff 100644 --- a/flowapp/auth.py +++ b/flowapp/auth.py @@ -14,7 +14,10 @@ def auth_required(f): @wraps(f) def decorated(*args, **kwargs): if not check_auth(get_user()): - return redirect("/login") + if current_app.config.get("SSO_AUTH"): + return redirect("/login") + elif current_app.config.get("HEADER_AUTH", False): + return redirect("/ext-login") return f(*args, **kwargs) return decorated @@ -99,6 +102,12 @@ def check_auth(uuid): if uuid: exist = db.session.query(User).filter_by(uuid=uuid).first() return exist + elif current_app.config.get("HEADER_AUTH", False): + # External auth (for example apache) + header_name = current_app.config.get("AUTH_HEADER_NAME", 'X-Authenticated-User') + if header_name not in request.headers or not session.get("user_uuid"): + return False + return db.session.query(User).filter_by(uuid=request.headers.get(header_name)) else: # Localhost login / no check session["user_email"] = current_app.config["LOCAL_USER_UUID"] diff --git a/flowapp/templates/errors/401.j2 b/flowapp/templates/errors/401.j2 new file mode 100755 index 00000000..6fea372d --- /dev/null +++ b/flowapp/templates/errors/401.j2 @@ -0,0 +1,7 @@ +{% extends 'layouts/default.j2' %} +{% block content %} +

Could not log you in.

+

401: Unauthorized

+

Please log out and try logging in again.

+

Log out

+{% endblock %} \ No newline at end of file From 4c1ece53bf19c9645c9cfcfe48fb640675db926f Mon Sep 17 00:00:00 2001 From: Jiri Vrany Date: Fri, 3 Nov 2023 13:01:12 +0100 Subject: [PATCH 02/26] version 0.7.3, simple auth mode available, docs for auth created --- README.md | 1 + docs/AUTH.md | 43 ++++++++++++++++++++++++++++++++++++++++ docs/INSTALL.md | 24 +++------------------- docs/apache.conf.example | 24 ++++++++++++++++++++++ flowapp/__about__.py | 2 +- flowapp/__init__.py | 2 -- 6 files changed, 72 insertions(+), 24 deletions(-) create mode 100644 docs/AUTH.md create mode 100644 docs/apache.conf.example diff --git a/README.md b/README.md index caaba34f..e6c50587 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ Last part of the system is Guarda service. This systemctl service is running in * [Local database instalation notes](./docs/DB_LOCAL.md) ## Change Log +- 0.7.3 - New possibility of external auth proxy. - 0.7.2 - Dashboard and Main menu are now customizable in config. App is ready to be packaged using setup.py. - 0.7.0 - ExaAPI now have two options - HTTP or RabbitMQ. ExaAPI process has been renamed, update of ExaBGP process value is needed for this version. - 0.6.2 - External config for ExaAPI diff --git a/docs/AUTH.md b/docs/AUTH.md new file mode 100644 index 00000000..9b6fdcb6 --- /dev/null +++ b/docs/AUTH.md @@ -0,0 +1,43 @@ +# ExaFS tool +## Auth mechanism + +Since version 0.7.3, the application supports three different forms of user authorization. + +* SSO using Shibboleth +* Simple Auth proxy +* Local single-user mode + +### SSO +To use SSO, you need to set up Apache + Shiboleth in the usual way. Then set `SSO_AUTH = True` in the application configuration file **config.py** + +Shibboleth configuration example: + +#### shibboleth config: +``` + + AuthType shibboleth + ShibRequestSetting requireSession 1 + require shib-session + + +``` + + +#### httpd ssl.conf +We recomend using app with https only. It's important to configure proxy pass to uwsgi in httpd config. +``` +# Proxy everything to the WSGI server except /Shibboleth.sso and +# /shibboleth-sp +ProxyPass /kon.php ! +ProxyPass /Shibboleth.sso ! +ProxyPass /shibboleth-sp ! +ProxyPass / uwsgi://127.0.0.1:8000/ +``` + +### Simple Auth +This mode uses a WWW server (usually Apache) as an auth proxy. It is thus possible to use an external user database. Everything needs to be set in the web server configuration, then in **config.py** enable `HEADER_AUTH = True` and set `AUTH_HEADER_NAME = 'X-Authenticated-User'` + +See [apache.conf.example]('./apache.example.conf') for more information about configuration. + +### Local single user mode +This mode is used as a fallback if neither SSO nor Simple Auth is enabled. Configuration is done using **config.py**. The mode is more for testing purposes, it does not allow to set up multiple users with different permission levels and also does not perform user authentication. \ No newline at end of file diff --git a/docs/INSTALL.md b/docs/INSTALL.md index f384845e..9032563f 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -8,30 +8,12 @@ The default Python for RHEL9 is Python 3.9 Virtualenv with Python39 is used by uWSGI server to keep the packages for app separated from system. ## Prerequisites +First, choose how to [authenticate and authorize users]('./AUTH.md'). The application currently supports three options. -ExaFS is using Shibboleth auth and therefore we suggest to use Apache web server. -Install the Apache httpd as usual and then continue with this guide. +Depending on the selected WWW server, set up a proxy. We recommend using Apache + mod_uwsgi. If you use another solution, set up the WWW server as you are used to. -First configure Shibboleth - -### shibboleth config: -``` - - AuthType shibboleth - ShibRequestSetting requireSession 1 - require shib-session - - -``` - -### httpd ssl.conf -We are using https only. It's important to configure proxy pass to uwsgi in httpd config. ``` -# Proxy everything to the WSGI server except /Shibboleth.sso and -# /shibboleth-sp -ProxyPass /kon.php ! -ProxyPass /Shibboleth.sso ! -ProxyPass /shibboleth-sp ! +# Proxy everything to the WSGI server ProxyPass / uwsgi://127.0.0.1:8000/ ``` diff --git a/docs/apache.conf.example b/docs/apache.conf.example new file mode 100644 index 00000000..d3cae299 --- /dev/null +++ b/docs/apache.conf.example @@ -0,0 +1,24 @@ +# mod_dbd configuration +DBDriver pgsql +DBDParams "dbname=exafs_users host=localhost user=exafs password=verysecurepassword" + +DBDMin 4 +DBDKeep 8 +DBDMax 20 +DBDExptime 300 + +# ExaFS authentication + + ServerName example.com + DocumentRoot /var/www/html + + + AuthType Basic + AuthName "Database Authentication" + AuthBasicProvider dbd + AuthDBDUserPWQuery "SELECT pass_hash AS password FROM \"users\" WHERE email = %s" + Require valid-user + RequestHeader set X-Authenticated-User expr=%{REMOTE_USER} + ProxyPass http://127.0.0.1:8080/ + + \ No newline at end of file diff --git a/flowapp/__about__.py b/flowapp/__about__.py index b7b96e61..be0f7d4e 100755 --- a/flowapp/__about__.py +++ b/flowapp/__about__.py @@ -1 +1 @@ -__version__ = "0.7.2" +__version__ = "0.7.3" diff --git a/flowapp/__init__.py b/flowapp/__init__.py index 83433e06..536ac51f 100644 --- a/flowapp/__init__.py +++ b/flowapp/__init__.py @@ -70,7 +70,6 @@ def login(user_info): uuid = False return redirect("/") else: - user = db.session.query(models.User).filter_by(uuid=uuid).first() try: _register_user_to_session(uuid) except AttributeError: @@ -194,4 +193,3 @@ def _register_user_to_session(uuid: str): session["can_edit"] = True if all(roles) and roles else [] return app - From cb93fbe925dfe21c408edc12e524964a8f1ae0d1 Mon Sep 17 00:00:00 2001 From: Jiri Vrany Date: Fri, 3 Nov 2023 13:04:14 +0100 Subject: [PATCH 03/26] version 0.7.3, simple auth mode available, docs for auth created --- docs/AUTH.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/AUTH.md b/docs/AUTH.md index 9b6fdcb6..d1b6a31b 100644 --- a/docs/AUTH.md +++ b/docs/AUTH.md @@ -37,7 +37,7 @@ ProxyPass / uwsgi://127.0.0.1:8000/ ### Simple Auth This mode uses a WWW server (usually Apache) as an auth proxy. It is thus possible to use an external user database. Everything needs to be set in the web server configuration, then in **config.py** enable `HEADER_AUTH = True` and set `AUTH_HEADER_NAME = 'X-Authenticated-User'` -See [apache.conf.example]('./apache.example.conf') for more information about configuration. +See [apache.conf.example](./apache.conf.example) for more information about configuration. ### Local single user mode This mode is used as a fallback if neither SSO nor Simple Auth is enabled. Configuration is done using **config.py**. The mode is more for testing purposes, it does not allow to set up multiple users with different permission levels and also does not perform user authentication. \ No newline at end of file From 061f40fd0629c79f7d21db972c3ec72c8783ba52 Mon Sep 17 00:00:00 2001 From: Jiri Vrany Date: Fri, 3 Nov 2023 13:07:55 +0100 Subject: [PATCH 04/26] typo in link --- docs/INSTALL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 9032563f..9965ee2e 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -8,7 +8,7 @@ The default Python for RHEL9 is Python 3.9 Virtualenv with Python39 is used by uWSGI server to keep the packages for app separated from system. ## Prerequisites -First, choose how to [authenticate and authorize users]('./AUTH.md'). The application currently supports three options. +First, choose how to [authenticate and authorize users](./AUTH.md). The application currently supports three options. Depending on the selected WWW server, set up a proxy. We recommend using Apache + mod_uwsgi. If you use another solution, set up the WWW server as you are used to. From e86ac0f94cb85eacc85efdbf32b4d76b6022b4b5 Mon Sep 17 00:00:00 2001 From: Jiri Vrany Date: Thu, 18 Jan 2024 13:01:38 +0100 Subject: [PATCH 05/26] Bugfix/autoescape (#35) * rename all j2 files back to html * add Markup to dashboard to render tables from macros --- config.example.py | 4 +- flowapp/__init__.py | 8 ++-- flowapp/instance_config.py | 6 +-- flowapp/templates/errors/{401.j2 => 401.html} | 2 +- flowapp/templates/errors/{404.j2 => 404.html} | 2 +- flowapp/templates/errors/{500.j2 => 500.html} | 2 +- .../forms/{api_key.j2 => api_key.html} | 4 +- .../forms/{ipv4_rule.j2 => ipv4_rule.html} | 4 +- .../forms/{ipv6_rule.j2 => ipv6_rule.html} | 4 +- .../forms/{macros.j2 => macros.html} | 0 .../forms/{rtbh_rule.j2 => rtbh_rule.html} | 4 +- .../templates/forms/{rule.j2 => rule.html} | 2 +- .../{simple_form.j2 => simple_form.html} | 4 +- .../layouts/{default.j2 => default.html} | 0 flowapp/templates/{macros.j2 => macros.html} | 0 .../pages/{actions.j2 => actions.html} | 2 +- .../pages/{api_key.j2 => api_key.html} | 2 +- .../pages/{as_paths.j2 => as_paths.html} | 2 +- .../{communities.j2 => communities.html} | 2 +- ...ashboard_admin.j2 => dashboard_admin.html} | 4 +- ...hboard_search.j2 => dashboard_search.html} | 6 +-- ...{dashboard_user.j2 => dashboard_user.html} | 6 +-- ...{dashboard_view.j2 => dashboard_view.html} | 6 +-- ...ashboard_whois.j2 => dashboard_whois.html} | 2 +- .../pages/{logout.j2 => logout.html} | 2 +- .../templates/pages/{logs.j2 => logs.html} | 2 +- .../templates/pages/{orgs.j2 => orgs.html} | 2 +- ...nu_dashboard.j2 => submenu_dashboard.html} | 0 ...rd_view.j2 => submenu_dashboard_view.html} | 0 .../templates/pages/{users.j2 => users.html} | 2 +- flowapp/views/admin.py | 32 ++++++------- flowapp/views/api_keys.py | 4 +- flowapp/views/dashboard.py | 45 ++++++++++--------- flowapp/views/rules.py | 14 +++--- 34 files changed, 91 insertions(+), 90 deletions(-) rename flowapp/templates/errors/{401.j2 => 401.html} (83%) rename flowapp/templates/errors/{404.j2 => 404.html} (78%) rename flowapp/templates/errors/{500.j2 => 500.html} (76%) rename flowapp/templates/forms/{api_key.j2 => api_key.html} (88%) rename flowapp/templates/forms/{ipv4_rule.j2 => ipv4_rule.html} (96%) rename flowapp/templates/forms/{ipv6_rule.j2 => ipv6_rule.html} (96%) rename flowapp/templates/forms/{macros.j2 => macros.html} (100%) rename flowapp/templates/forms/{rtbh_rule.j2 => rtbh_rule.html} (95%) rename flowapp/templates/forms/{rule.j2 => rule.html} (98%) rename flowapp/templates/forms/{simple_form.j2 => simple_form.html} (70%) rename flowapp/templates/layouts/{default.j2 => default.html} (100%) rename flowapp/templates/{macros.j2 => macros.html} (100%) rename flowapp/templates/pages/{actions.j2 => actions.html} (96%) rename flowapp/templates/pages/{api_key.j2 => api_key.html} (95%) rename flowapp/templates/pages/{as_paths.j2 => as_paths.html} (95%) rename flowapp/templates/pages/{communities.j2 => communities.html} (97%) rename flowapp/templates/pages/{dashboard_admin.j2 => dashboard_admin.html} (89%) rename flowapp/templates/pages/{dashboard_search.j2 => dashboard_search.html} (73%) rename flowapp/templates/pages/{dashboard_user.j2 => dashboard_user.html} (88%) rename flowapp/templates/pages/{dashboard_view.j2 => dashboard_view.html} (75%) rename flowapp/templates/pages/{dashboard_whois.j2 => dashboard_whois.html} (83%) rename flowapp/templates/pages/{logout.j2 => logout.html} (79%) rename flowapp/templates/pages/{logs.j2 => logs.html} (97%) rename flowapp/templates/pages/{orgs.j2 => orgs.html} (95%) rename flowapp/templates/pages/{submenu_dashboard.j2 => submenu_dashboard.html} (100%) rename flowapp/templates/pages/{submenu_dashboard_view.j2 => submenu_dashboard_view.html} (100%) rename flowapp/templates/pages/{users.j2 => users.html} (96%) diff --git a/config.example.py b/config.example.py index 956ba963..08c53ba7 100644 --- a/config.example.py +++ b/config.example.py @@ -8,10 +8,10 @@ class Config(): # Flask testing TESTING = False # SSO auth enabled - SSO_AUTH = False + SSO_AUTH = True # Authentication is done outside the app, use HTTP header to get the user uuid. # If SSO_AUTH is set to True, this option is ignored and SSO auth is used. - HEADER_AUTH = True + HEADER_AUTH = False # Name of HTTP header containing the UUID of authenticated user. # Only used when HEADER_AUTH is set to True AUTH_HEADER_NAME = 'X-Authenticated-User' diff --git a/flowapp/__init__.py b/flowapp/__init__.py index 536ac51f..95b8afdc 100644 --- a/flowapp/__init__.py +++ b/flowapp/__init__.py @@ -87,13 +87,13 @@ def logout(): def ext_login(): header_name = app.config.get("AUTH_HEADER_NAME", 'X-Authenticated-User') if header_name not in request.headers: - return render_template("errors/401.j2") + return render_template("errors/401.html") uuid = request.headers.get(header_name) if uuid: try: _register_user_to_session(uuid) except AttributeError: - return render_template("errors/401.j2") + return render_template("errors/401.html") return redirect("/") @app.route("/") @@ -136,12 +136,12 @@ def shutdown_session(exception=None): # HTTP error handling @app.errorhandler(404) def not_found(error): - return render_template("errors/404.j2"), 404 + return render_template("errors/404.html"), 404 @app.errorhandler(500) def internal_error(exception): app.logger.error(exception) - return render_template("errors/500.j2"), 500 + return render_template("errors/500.html"), 500 @app.context_processor def utility_processor(): diff --git a/flowapp/instance_config.py b/flowapp/instance_config.py index d2ae8743..548261be 100644 --- a/flowapp/instance_config.py +++ b/flowapp/instance_config.py @@ -99,7 +99,7 @@ class InstanceConfig: DASHBOARD = { "ipv4": { "name": "IPv4", - "macro_file": "macros.j2", + "macro_file": "macros.html", "macro_tbody": "build_ip_tbody", "macro_thead": "build_rules_thead", "table_colspan": 10, @@ -107,7 +107,7 @@ class InstanceConfig: }, "ipv6": { "name": "IPv6", - "macro_file": "macros.j2", + "macro_file": "macros.html", "macro_tbody": "build_ip_tbody", "macro_thead": "build_rules_thead", "table_colspan": 10, @@ -115,7 +115,7 @@ class InstanceConfig: }, "rtbh": { "name": "RTBH", - "macro_file": "macros.j2", + "macro_file": "macros.html", "macro_tbody": "build_rtbh_tbody", "macro_thead": "build_rules_thead", "table_colspan": 5, diff --git a/flowapp/templates/errors/401.j2 b/flowapp/templates/errors/401.html similarity index 83% rename from flowapp/templates/errors/401.j2 rename to flowapp/templates/errors/401.html index 6fea372d..6da05328 100755 --- a/flowapp/templates/errors/401.j2 +++ b/flowapp/templates/errors/401.html @@ -1,4 +1,4 @@ -{% extends 'layouts/default.j2' %} +{% extends 'layouts/default.html' %} {% block content %}

Could not log you in.

401: Unauthorized

diff --git a/flowapp/templates/errors/404.j2 b/flowapp/templates/errors/404.html similarity index 78% rename from flowapp/templates/errors/404.j2 rename to flowapp/templates/errors/404.html index 0bd068c0..8bccd1d7 100644 --- a/flowapp/templates/errors/404.j2 +++ b/flowapp/templates/errors/404.html @@ -1,4 +1,4 @@ -{% extends 'layouts/default.j2' %} +{% extends 'layouts/default.html' %} {% block content %}

Sorry ...

There's nothing here!

diff --git a/flowapp/templates/errors/500.j2 b/flowapp/templates/errors/500.html similarity index 76% rename from flowapp/templates/errors/500.j2 rename to flowapp/templates/errors/500.html index ff0a04be..e6aa9ebf 100644 --- a/flowapp/templates/errors/500.j2 +++ b/flowapp/templates/errors/500.html @@ -1,4 +1,4 @@ -{% extends 'layouts/default.j2' %} +{% extends 'layouts/default.html' %} {% block content %}

Error ...

Sorry ;-)

diff --git a/flowapp/templates/forms/api_key.j2 b/flowapp/templates/forms/api_key.html similarity index 88% rename from flowapp/templates/forms/api_key.j2 rename to flowapp/templates/forms/api_key.html index d1128583..9d8901cb 100644 --- a/flowapp/templates/forms/api_key.j2 +++ b/flowapp/templates/forms/api_key.html @@ -1,5 +1,5 @@ -{% extends 'layouts/default.j2' %} -{% from 'forms/macros.j2' import render_field %} +{% extends 'layouts/default.html' %} +{% from 'forms/macros.html' import render_field %} {% block title %}Add New Machine with ApiKey{% endblock %} {% block content %}

Add new ApiKey for your machine

diff --git a/flowapp/templates/forms/ipv4_rule.j2 b/flowapp/templates/forms/ipv4_rule.html similarity index 96% rename from flowapp/templates/forms/ipv4_rule.j2 rename to flowapp/templates/forms/ipv4_rule.html index 38427809..c1c7d233 100644 --- a/flowapp/templates/forms/ipv4_rule.j2 +++ b/flowapp/templates/forms/ipv4_rule.html @@ -1,5 +1,5 @@ -{% extends 'layouts/default.j2' %} -{% from 'forms/macros.j2' import render_field %} +{% extends 'layouts/default.html' %} +{% from 'forms/macros.html' import render_field %} {% block title %}Add IPv4 rule{% endblock %} {% block content %}

{{ title or 'New'}} IPv4 rule

diff --git a/flowapp/templates/forms/ipv6_rule.j2 b/flowapp/templates/forms/ipv6_rule.html similarity index 96% rename from flowapp/templates/forms/ipv6_rule.j2 rename to flowapp/templates/forms/ipv6_rule.html index 2732ee7e..8929c99e 100644 --- a/flowapp/templates/forms/ipv6_rule.j2 +++ b/flowapp/templates/forms/ipv6_rule.html @@ -1,5 +1,5 @@ -{% extends 'layouts/default.j2' %} -{% from 'forms/macros.j2' import render_field %} +{% extends 'layouts/default.html' %} +{% from 'forms/macros.html' import render_field %} {% block title %}Add IPv6 rule{% endblock %} {% block content %}

{{ title or 'New'}} IPv6 rule

diff --git a/flowapp/templates/forms/macros.j2 b/flowapp/templates/forms/macros.html similarity index 100% rename from flowapp/templates/forms/macros.j2 rename to flowapp/templates/forms/macros.html diff --git a/flowapp/templates/forms/rtbh_rule.j2 b/flowapp/templates/forms/rtbh_rule.html similarity index 95% rename from flowapp/templates/forms/rtbh_rule.j2 rename to flowapp/templates/forms/rtbh_rule.html index ebb0e8b1..986c081b 100644 --- a/flowapp/templates/forms/rtbh_rule.j2 +++ b/flowapp/templates/forms/rtbh_rule.html @@ -1,5 +1,5 @@ -{% extends 'layouts/default.j2' %} -{% from 'forms/macros.j2' import render_field %} +{% extends 'layouts/default.html' %} +{% from 'forms/macros.html' import render_field %} {% block title %}Add RTBH rule{% endblock %} {% block content %}

{{ title or 'New'}} RTBH rule

diff --git a/flowapp/templates/forms/rule.j2 b/flowapp/templates/forms/rule.html similarity index 98% rename from flowapp/templates/forms/rule.j2 rename to flowapp/templates/forms/rule.html index 5d03bc4d..1a1c94bb 100644 --- a/flowapp/templates/forms/rule.j2 +++ b/flowapp/templates/forms/rule.html @@ -1,4 +1,4 @@ -{% extends 'layouts/default.j2' %} +{% extends 'layouts/default.html' %} {% block title %}Add IPv4 rule{% endblock %} {% block content %}
diff --git a/flowapp/templates/forms/simple_form.j2 b/flowapp/templates/forms/simple_form.html similarity index 70% rename from flowapp/templates/forms/simple_form.j2 rename to flowapp/templates/forms/simple_form.html index 4e91260d..170c74a9 100644 --- a/flowapp/templates/forms/simple_form.j2 +++ b/flowapp/templates/forms/simple_form.html @@ -1,5 +1,5 @@ -{% extends 'layouts/default.j2' %} -{% from 'forms/macros.j2' import render_form %} +{% extends 'layouts/default.html' %} +{% from 'forms/macros.html' import render_form %} {% block title %} {{ title }} diff --git a/flowapp/templates/layouts/default.j2 b/flowapp/templates/layouts/default.html similarity index 100% rename from flowapp/templates/layouts/default.j2 rename to flowapp/templates/layouts/default.html diff --git a/flowapp/templates/macros.j2 b/flowapp/templates/macros.html similarity index 100% rename from flowapp/templates/macros.j2 rename to flowapp/templates/macros.html diff --git a/flowapp/templates/pages/actions.j2 b/flowapp/templates/pages/actions.html similarity index 96% rename from flowapp/templates/pages/actions.j2 rename to flowapp/templates/pages/actions.html index cdf73e15..44b904b4 100644 --- a/flowapp/templates/pages/actions.j2 +++ b/flowapp/templates/pages/actions.html @@ -1,4 +1,4 @@ -{% extends 'layouts/default.j2' %} +{% extends 'layouts/default.html' %} {% block title %}Flowspec Actions{% endblock %} {% block content %} diff --git a/flowapp/templates/pages/api_key.j2 b/flowapp/templates/pages/api_key.html similarity index 95% rename from flowapp/templates/pages/api_key.j2 rename to flowapp/templates/pages/api_key.html index dabba735..0b3ee32b 100644 --- a/flowapp/templates/pages/api_key.j2 +++ b/flowapp/templates/pages/api_key.html @@ -1,4 +1,4 @@ -{% extends 'layouts/default.j2' %} +{% extends 'layouts/default.html' %} {% block title %}ExaFS - ApiKeys{% endblock %} {% block content %}

Your machines and ApiKeys

diff --git a/flowapp/templates/pages/as_paths.j2 b/flowapp/templates/pages/as_paths.html similarity index 95% rename from flowapp/templates/pages/as_paths.j2 rename to flowapp/templates/pages/as_paths.html index 97cf0134..369fda03 100644 --- a/flowapp/templates/pages/as_paths.j2 +++ b/flowapp/templates/pages/as_paths.html @@ -1,4 +1,4 @@ -{% extends 'layouts/default.j2' %} +{% extends 'layouts/default.html' %} {% block title %}AS Paths{% endblock %} {% block content %}
diff --git a/flowapp/templates/pages/communities.j2 b/flowapp/templates/pages/communities.html similarity index 97% rename from flowapp/templates/pages/communities.j2 rename to flowapp/templates/pages/communities.html index ce39b54c..b0005bbc 100644 --- a/flowapp/templates/pages/communities.j2 +++ b/flowapp/templates/pages/communities.html @@ -1,4 +1,4 @@ -{% extends 'layouts/default.j2' %} +{% extends 'layouts/default.html' %} {% block title %}Flowspec RTBH communities{% endblock %} {% block content %}
diff --git a/flowapp/templates/pages/dashboard_admin.j2 b/flowapp/templates/pages/dashboard_admin.html similarity index 89% rename from flowapp/templates/pages/dashboard_admin.j2 rename to flowapp/templates/pages/dashboard_admin.html index 00d6caff..df140526 100644 --- a/flowapp/templates/pages/dashboard_admin.j2 +++ b/flowapp/templates/pages/dashboard_admin.html @@ -1,10 +1,10 @@ -{% extends 'layouts/default.j2' %} +{% extends 'layouts/default.html' %} {% block title %}Flowspec{% endblock %} {% block content %} - {% include 'pages/submenu_dashboard.j2' %} + {% include 'pages/submenu_dashboard.html' %} {% if display_rules %}
diff --git a/flowapp/templates/pages/dashboard_search.j2 b/flowapp/templates/pages/dashboard_search.html similarity index 73% rename from flowapp/templates/pages/dashboard_search.j2 rename to flowapp/templates/pages/dashboard_search.html index 12405d4b..c56442f1 100644 --- a/flowapp/templates/pages/dashboard_search.j2 +++ b/flowapp/templates/pages/dashboard_search.html @@ -1,10 +1,10 @@ -{% extends 'layouts/default.j2' %} -{% from 'macros.j2' import build_ip_tbody, build_rtbh_tbody, build_rules_thead %} +{% extends 'layouts/default.html' %} +{% from 'macros.html' import build_ip_tbody, build_rtbh_tbody, build_rules_thead %} {% block title %}Flowspec{% endblock %} {% block content %} - {% include 'pages/submenu_dashboard.j2' %} + {% include 'pages/submenu_dashboard.html' %}
diff --git a/flowapp/templates/pages/dashboard_user.j2 b/flowapp/templates/pages/dashboard_user.html similarity index 88% rename from flowapp/templates/pages/dashboard_user.j2 rename to flowapp/templates/pages/dashboard_user.html index dd68f295..683f5436 100644 --- a/flowapp/templates/pages/dashboard_user.j2 +++ b/flowapp/templates/pages/dashboard_user.html @@ -1,11 +1,11 @@ -{% extends 'layouts/default.j2' %} -{% from 'macros.j2' import build_ip_tbody, build_rtbh_tbody, build_rules_thead %} +{% extends 'layouts/default.html' %} +{% from 'macros.html' import build_ip_tbody, build_rtbh_tbody, build_rules_thead %} {% block title %}Flowspec{% endblock %} {% block content %} - {% include 'pages/submenu_dashboard.j2' %} + {% include 'pages/submenu_dashboard.html' %} diff --git a/flowapp/templates/pages/dashboard_view.j2 b/flowapp/templates/pages/dashboard_view.html similarity index 75% rename from flowapp/templates/pages/dashboard_view.j2 rename to flowapp/templates/pages/dashboard_view.html index ee36d2d6..ae14146c 100644 --- a/flowapp/templates/pages/dashboard_view.j2 +++ b/flowapp/templates/pages/dashboard_view.html @@ -1,11 +1,11 @@ -{% extends 'layouts/default.j2' %} -{% from 'macros.j2' import build_ip_tbody, build_rtbh_tbody, build_rules_thead %} +{% extends 'layouts/default.html' %} +{% from 'macros.html' import build_ip_tbody, build_rtbh_tbody, build_rules_thead %} {% block title %}Flowspec{% endblock %} {% block content %} - {% include 'pages/submenu_dashboard_view.j2' %} + {% include 'pages/submenu_dashboard_view.html' %} {% if display_rules %}

{{ rstate|capitalize }} {{ table_title }}

diff --git a/flowapp/templates/pages/dashboard_whois.j2 b/flowapp/templates/pages/dashboard_whois.html similarity index 83% rename from flowapp/templates/pages/dashboard_whois.j2 rename to flowapp/templates/pages/dashboard_whois.html index 6d7162aa..d8a75c8d 100644 --- a/flowapp/templates/pages/dashboard_whois.j2 +++ b/flowapp/templates/pages/dashboard_whois.html @@ -1,4 +1,4 @@ -{% extends 'layouts/default.j2' %} +{% extends 'layouts/default.html' %} {% block title %}Flowspec{% endblock %} {% block content %} diff --git a/flowapp/templates/pages/logout.j2 b/flowapp/templates/pages/logout.html similarity index 79% rename from flowapp/templates/pages/logout.j2 rename to flowapp/templates/pages/logout.html index 88bb23ea..58a18020 100644 --- a/flowapp/templates/pages/logout.j2 +++ b/flowapp/templates/pages/logout.html @@ -1,4 +1,4 @@ -{% extends 'layouts/default.j2' %} +{% extends 'layouts/default.html' %} {% block title %}Flowspec - logout{% endblock %} {% block content %}

Good Bye

diff --git a/flowapp/templates/pages/logs.j2 b/flowapp/templates/pages/logs.html similarity index 97% rename from flowapp/templates/pages/logs.j2 rename to flowapp/templates/pages/logs.html index 8176e96a..7816677e 100644 --- a/flowapp/templates/pages/logs.j2 +++ b/flowapp/templates/pages/logs.html @@ -1,4 +1,4 @@ -{% extends 'layouts/default.j2' %} +{% extends 'layouts/default.html' %} {% block title %}Flowspec Users{% endblock %} {% block content %}

Commands log / latest on top

diff --git a/flowapp/templates/pages/orgs.j2 b/flowapp/templates/pages/orgs.html similarity index 95% rename from flowapp/templates/pages/orgs.j2 rename to flowapp/templates/pages/orgs.html index d9af2f6b..3184160a 100644 --- a/flowapp/templates/pages/orgs.j2 +++ b/flowapp/templates/pages/orgs.html @@ -1,4 +1,4 @@ -{% extends 'layouts/default.j2' %} +{% extends 'layouts/default.html' %} {% block title %}Flowspec Organziations{% endblock %} {% block content %}
diff --git a/flowapp/templates/pages/submenu_dashboard.j2 b/flowapp/templates/pages/submenu_dashboard.html similarity index 100% rename from flowapp/templates/pages/submenu_dashboard.j2 rename to flowapp/templates/pages/submenu_dashboard.html diff --git a/flowapp/templates/pages/submenu_dashboard_view.j2 b/flowapp/templates/pages/submenu_dashboard_view.html similarity index 100% rename from flowapp/templates/pages/submenu_dashboard_view.j2 rename to flowapp/templates/pages/submenu_dashboard_view.html diff --git a/flowapp/templates/pages/users.j2 b/flowapp/templates/pages/users.html similarity index 96% rename from flowapp/templates/pages/users.j2 rename to flowapp/templates/pages/users.html index 46e2ae40..f230abdb 100644 --- a/flowapp/templates/pages/users.j2 +++ b/flowapp/templates/pages/users.html @@ -1,4 +1,4 @@ -{% extends 'layouts/default.j2' %} +{% extends 'layouts/default.html' %} {% block title %}Flowspec Users{% endblock %} {% block content %}
diff --git a/flowapp/views/admin.py b/flowapp/views/admin.py index 8c996b51..4b8cc66b 100644 --- a/flowapp/views/admin.py +++ b/flowapp/views/admin.py @@ -39,7 +39,7 @@ def log(page): .filter(Log.time > week_ago) .paginate(page=page, per_page=per_page, max_per_page=None, error_out=False) ) - return render_template("pages/logs.j2", logs=logs) + return render_template("pages/logs.html", logs=logs) @admin.route("/user", methods=["GET", "POST"]) @@ -74,7 +74,7 @@ def user(): action_url = url_for("admin.user") return render_template( - "forms/simple_form.j2", + "forms/simple_form.html", title="Add new user to Flowspec", form=form, action_url=action_url, @@ -103,7 +103,7 @@ def edit_user(user_id): action_url = url_for("admin.edit_user", user_id=user_id) return render_template( - "forms/simple_form.j2", + "forms/simple_form.html", title="Editing {}".format(user.email), form=form, action_url=action_url, @@ -136,7 +136,7 @@ def delete_user(user_id): @admin_required def users(): users = User.query.all() - return render_template("pages/users.j2", users=users) + return render_template("pages/users.html", users=users) @admin.route("/organizations") @@ -144,7 +144,7 @@ def users(): @admin_required def organizations(): orgs = db.session.query(Organization).all() - return render_template("pages/orgs.j2", orgs=orgs) + return render_template("pages/orgs.html", orgs=orgs) @admin.route("/organization", methods=["GET", "POST"]) @@ -169,7 +169,7 @@ def organization(): action_url = url_for("admin.organization") return render_template( - "forms/simple_form.j2", + "forms/simple_form.html", title="Add new organization to Flowspec", form=form, action_url=action_url, @@ -191,7 +191,7 @@ def edit_organization(org_id): action_url = url_for("admin.edit_organization", org_id=org.id) return render_template( - "forms/simple_form.j2", + "forms/simple_form.html", title="Editing {}".format(org.name), form=form, action_url=action_url, @@ -224,7 +224,7 @@ def delete_organization(org_id): @admin_required def as_paths(): mpaths = db.session.query(ASPath).all() - return render_template("pages/as_paths.j2", paths=mpaths) + return render_template("pages/as_paths.html", paths=mpaths) @admin.route("/as-path", methods=["GET", "POST"]) @@ -247,7 +247,7 @@ def as_path(): action_url = url_for("admin.as_path") return render_template( - "forms/simple_form.j2", + "forms/simple_form.html", title="Add new AS-path to Flowspec", form=form, action_url=action_url, @@ -269,7 +269,7 @@ def edit_as_path(path_id): action_url = url_for("admin.edit_as_path", path_id=pth.id) return render_template( - "forms/simple_form.j2", + "forms/simple_form.html", title="Editing {}".format(pth.prefix), form=form, action_url=action_url, @@ -296,7 +296,7 @@ def delete_as_path(path_id): @admin_required def actions(): actions = db.session.query(Action).all() - return render_template("pages/actions.j2", actions=actions) + return render_template("pages/actions.html", actions=actions) @admin.route("/action", methods=["GET", "POST"]) @@ -329,7 +329,7 @@ def action(): action_url = url_for("admin.action") return render_template( - "forms/simple_form.j2", + "forms/simple_form.html", title="Add new action to Flowspec", form=form, action_url=action_url, @@ -351,7 +351,7 @@ def edit_action(action_id): action_url = url_for("admin.edit_action", action_id=action.id) return render_template( - "forms/simple_form.j2", + "forms/simple_form.html", title="Editing {}".format(action.name), form=form, action_url=action_url, @@ -383,7 +383,7 @@ def delete_action(action_id): @admin_required def communities(): communities = db.session.query(Community).all() - return render_template("pages/communities.j2", communities=communities) + return render_template("pages/communities.html", communities=communities) @admin.route("/community", methods=["GET", "POST"]) @@ -416,7 +416,7 @@ def community(): community_url = url_for("admin.community") return render_template( - "forms/simple_form.j2", + "forms/simple_form.html", title="Add new community to Flowspec", form=form, community_url=community_url, @@ -438,7 +438,7 @@ def edit_community(community_id): community_url = url_for("admin.edit_community", community_id=community.id) return render_template( - "forms/simple_form.j2", + "forms/simple_form.html", title="Editing {}".format(community.name), form=form, community_url=community_url, diff --git a/flowapp/views/api_keys.py b/flowapp/views/api_keys.py index cf909b01..a40c1ec5 100644 --- a/flowapp/views/api_keys.py +++ b/flowapp/views/api_keys.py @@ -35,7 +35,7 @@ def all(): payload = {"keys": [key.id for key in keys]} encoded = jwt.encode(payload, jwt_key, algorithm="HS256") - resp = make_response(render_template("pages/api_key.j2", keys=keys)) + resp = make_response(render_template("pages/api_key.html", keys=keys)) if current_app.config.get("DEVEL"): resp.set_cookie(COOKIE_KEY, encoded, httponly=True, samesite="Lax") @@ -73,7 +73,7 @@ def add(): % (getattr(form, field).label.text, error) ) - return render_template("forms/api_key.j2", form=form, generated_key=generated) + return render_template("forms/api_key.html", form=form, generated_key=generated) @api_keys.route("/delete/", methods=["GET"]) diff --git a/flowapp/views/dashboard.py b/flowapp/views/dashboard.py index 6c179d94..1f63159c 100644 --- a/flowapp/views/dashboard.py +++ b/flowapp/views/dashboard.py @@ -11,6 +11,7 @@ make_response, abort, ) +from markupsafe import Markup from flowapp import models, validators, flowspec from flowapp.auth import auth_required from flowapp.constants import ( @@ -36,7 +37,7 @@ def whois(ip_address): result = subprocess.run(["whois", ip_address], stdout=subprocess.PIPE) return render_template( - "pages/dashboard_whois.j2", + "pages/dashboard_whois.html", result=result.stdout.decode("utf-8"), ip_address=ip_address, ) @@ -74,7 +75,7 @@ def index(rtype=None, rstate="active"): # get the macros for the current rule type from config # warning no checks here, if the config is set to non existing macro the app will crash macro_file = ( - current_app.config["DASHBOARD"].get(rtype).get("macro_file", "macros.j2") + current_app.config["DASHBOARD"].get(rtype).get("macro_file", "macros.html") ) macro_tbody = ( current_app.config["DASHBOARD"].get(rtype).get("macro_tbody", "build_ip_tbody") @@ -160,7 +161,7 @@ def create_dashboard_table_body( rtype, editable=True, group_op=True, - macro_file="macros.j2", + macro_file="macros.html", macro_name="build_ip_tbody", ): """ @@ -193,7 +194,7 @@ def create_dashboard_table_head( sort_order, search_query="", group_op=True, - macro_file="macros.j2", + macro_file="macros.html", macro_name="build_rules_thead", ): """ @@ -232,7 +233,7 @@ def create_dashboard_table_head( def create_dashboard_table_foot( - colspan=10, macro_file="macros.j2", macro_name="build_group_buttons_tfoot" + colspan=10, macro_file="macros.html", macro_name="build_group_buttons_tfoot" ): """ create the table foot for the dashboard using a jinja2 macro @@ -260,7 +261,7 @@ def create_admin_response( table_title, search_query="", count_match=DEFAULT_COUNT_MATCH, - macro_file="macros.j2", + macro_file="macros.html", macro_tbody="build_ip_tbody", macro_thead="build_rules_thead", macro_tfoot="build_group_buttons_tfoot", @@ -298,14 +299,14 @@ def create_admin_response( res = make_response( render_template( - "pages/dashboard_admin.j2", + "pages/dashboard_admin.html", display_rules=len(rules), table_title=table_title, css_classes=active_css_rstate(rtype, rstate), count_match=count_match, - dashboard_table_body=dashboard_table_body, - dashboard_table_head=dashboard_table_head, - dashboard_table_foot=dashboard_table_foot, + dashboard_table_body=Markup(dashboard_table_body), + dashboard_table_head=Markup(dashboard_table_head), + dashboard_table_foot=Markup(dashboard_table_foot), rules_columns=table_columns, rtype=rtype, rstate=rstate, @@ -329,7 +330,7 @@ def create_user_response( table_title, search_query="", count_match=DEFAULT_COUNT_MATCH, - macro_file="macros.j2", + macro_file="macros.html", macro_tbody="build_ip_tbody", macro_thead="build_rules_thead", macro_tfoot="build_rules_tfoot", @@ -405,17 +406,17 @@ def create_user_response( res = make_response( render_template( - "pages/dashboard_user.j2", + "pages/dashboard_user.html", table_title=table_title, rules_columns=table_columns, - dashboard_table_editable=dashboard_table_editable, - dashboard_table_readonly=dashboard_table_readonly, + dashboard_table_editable=Markup(dashboard_table_editable), + dashboard_table_readonly=Markup(dashboard_table_readonly), display_editable=display_editable, display_readonly=display_readonly, css_classes=active_css_rstate(rtype, rstate), - dashboard_table_editable_head=dashboard_table_editable_head, - dashboard_table_readonly_head=dashboard_table_readonly_head, - dashboard_table_foot=dashboard_table_foot, + dashboard_table_editable_head=Markup(dashboard_table_editable_head), + dashboard_table_readonly_head=Markup(dashboard_table_readonly_head), + dashboard_table_foot=Markup(dashboard_table_foot), rtype=rtype, rstate=rstate, sort_key=sort_key, @@ -439,7 +440,7 @@ def create_view_response( table_title, search_query="", count_match=DEFAULT_COUNT_MATCH, - macro_file="macros.j2", + macro_file="macros.html", macro_tbody="build_ip_tbody", macro_thead="build_rules_thead", macro_tfoot="build_rules_tfoot", @@ -479,7 +480,7 @@ def create_view_response( res = make_response( render_template( - "pages/dashboard_view.j2", + "pages/dashboard_view.html", table_title=table_title, rules_columns=table_columns, display_rules=len(rules), @@ -488,9 +489,9 @@ def create_view_response( count_match=count_match, rstate=rstate, rtype=rtype, - dashboard_table_body=dashboard_table_body, - dashboard_table_head=dashboard_table_head, - dashboard_table_foot=dashboard_table_foot, + dashboard_table_body=Markup(dashboard_table_body), + dashboard_table_head=Markup(dashboard_table_head), + dashboard_table_foot=Markup(dashboard_table_foot), ) ) diff --git a/flowapp/views/rules.py b/flowapp/views/rules.py index 6b30b2e2..b2e29565 100644 --- a/flowapp/views/rules.py +++ b/flowapp/views/rules.py @@ -48,9 +48,9 @@ DATA_FORMS = {1: RTBHForm, 4: IPv4Form, 6: IPv6Form} DATA_FORMS_NAMED = {"rtbh": RTBHForm, "ipv4": IPv4Form, "ipv6": IPv6Form} DATA_TEMPLATES = { - 1: "forms/rtbh_rule.j2", - 4: "forms/ipv4_rule.j2", - 6: "forms/ipv6_rule.j2", + 1: "forms/rtbh_rule.html", + 4: "forms/ipv4_rule.html", + 6: "forms/ipv6_rule.html", } DATA_TABLES = {1: "RTBH", 4: "flowspec4", 6: "flowspec6"} DEFAULT_SORT = {1: "ivp4", 4: "source", 6: "source"} @@ -482,7 +482,7 @@ def ipv4_rule(): form.expires.data = default_expires return render_template( - "forms/ipv4_rule.j2", form=form, action_url=url_for("rules.ipv4_rule") + "forms/ipv4_rule.html", form=form, action_url=url_for("rules.ipv4_rule") ) @@ -559,7 +559,7 @@ def ipv6_rule(): form.expires.data = default_expires return render_template( - "forms/ipv6_rule.j2", form=form, action_url=url_for("rules.ipv6_rule") + "forms/ipv6_rule.html", form=form, action_url=url_for("rules.ipv6_rule") ) @@ -631,7 +631,7 @@ def rtbh_rule(): form.expires.data = default_expires return render_template( - "forms/rtbh_rule.j2", form=form, action_url=url_for("rules.rtbh_rule") + "forms/rtbh_rule.html", form=form, action_url=url_for("rules.rtbh_rule") ) @@ -651,7 +651,7 @@ def export(): announce_all_routes() return render_template( - "pages/dashboard_admin.j2", + "pages/dashboard_admin.html", rules=rules, actions=actions, rules_rtbh=rules_rtbh, From 6b020f1aa4da700e7316493eaf0d334ab7c19a3f Mon Sep 17 00:00:00 2001 From: Jiri Vrany Date: Thu, 25 Jan 2024 11:36:11 +0100 Subject: [PATCH 06/26] bugfix - V4 table cols, DOCS update --- docs/AUTH.md | 18 +++++++++++++++++- docs/INSTALL.md | 8 ++++---- flowapp/instance_config.py | 2 +- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/docs/AUTH.md b/docs/AUTH.md index d1b6a31b..edc5a1be 100644 --- a/docs/AUTH.md +++ b/docs/AUTH.md @@ -10,9 +10,11 @@ Since version 0.7.3, the application supports three different forms of user auth ### SSO To use SSO, you need to set up Apache + Shiboleth in the usual way. Then set `SSO_AUTH = True` in the application configuration file **config.py** +In general the whole app should be protected by Shiboleth. However, there certain endpoints should be excluded from Shiboleth for the interaction with BGP. See configuration example bellow. The endpoints which are not protected by Shibboleth are protected by app itself. Either by @localhost_only decorator or by API key. + Shibboleth configuration example: -#### shibboleth config: +#### shibboleth config (shib.conf): ``` AuthType shibboleth @@ -20,6 +22,20 @@ Shibboleth configuration example: require shib-session + + Satisfy Any + allow from All + + + + Satisfy Any + allow from All + + + + Satisfy Any + allow from All + ``` diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 9965ee2e..2589709b 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -125,9 +125,9 @@ Supervisord is used to run and manage application. #### Final steps - as deploy user -Copy config.example.py to config.py and fill out the DB credetials. +1. Copy config.example.py to config.py and fill out the DB credetials. -Create and populate database tables. +2. Create and populate database tables. ``` cd ~/www source venv/bin/activate @@ -135,8 +135,8 @@ python db-init.py ``` DB-init script inserts default roles, actions, rule states and two organizations (TUL and Cesnet). But no users. -So before start, use your favorite mysql admin tool and insert some users into database. -The uuid of user should be set the eppn value provided by Shibboleth. +3. Before start, **use your favorite mysql admin tool and insert some users into database**. +The **uuid** of user should be set the **eppn** value provided by Shibboleth. You can use following MYSQL commands to insert the user, give him role 'admin' and add him to the the organization 'Cesnet'. diff --git a/flowapp/instance_config.py b/flowapp/instance_config.py index 548261be..3c8bafbc 100644 --- a/flowapp/instance_config.py +++ b/flowapp/instance_config.py @@ -103,7 +103,7 @@ class InstanceConfig: "macro_tbody": "build_ip_tbody", "macro_thead": "build_rules_thead", "table_colspan": 10, - "table_columns": RULES_COLUMNS_V6, + "table_columns": RULES_COLUMNS_V4, }, "ipv6": { "name": "IPv6", From fe4ec95d3bddd4a4603fbb3b88b7087462c2668a Mon Sep 17 00:00:00 2001 From: Jiri Vrany Date: Fri, 8 Mar 2024 12:16:47 +0100 Subject: [PATCH 07/26] bugfix, closes #38 --- flowapp/models.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flowapp/models.py b/flowapp/models.py index 09304bc3..03c1963e 100644 --- a/flowapp/models.py +++ b/flowapp/models.py @@ -273,7 +273,7 @@ def to_dict(self, prefered_format="yearfirst"): """ if prefered_format == "timestamp": expires = int(datetime.timestamp(self.expires)) - created = int(datetime.timestamp(self.expires)) + created = int(datetime.timestamp(self.created)) else: expires = utils.datetime_to_webpicker(self.expires, prefered_format) created = utils.datetime_to_webpicker(self.created, prefered_format) @@ -422,7 +422,7 @@ def to_dict(self, prefered_format="yearfirst"): """ if prefered_format == "timestamp": expires = int(datetime.timestamp(self.expires)) - created = int(datetime.timestamp(self.expires)) + created = int(datetime.timestamp(self.created)) else: expires = utils.datetime_to_webpicker(self.expires, prefered_format) created = utils.datetime_to_webpicker(self.created, prefered_format) @@ -549,7 +549,7 @@ def to_dict(self, prefered_format="yearfirst"): """ if prefered_format == "timestamp": expires = int(datetime.timestamp(self.expires)) - created = int(datetime.timestamp(self.expires)) + created = int(datetime.timestamp(self.created)) else: expires = utils.datetime_to_webpicker(self.expires, prefered_format) created = utils.datetime_to_webpicker(self.created, prefered_format) From 45212306da473261ca1db87bbc68be6b3cc9c726 Mon Sep 17 00:00:00 2001 From: Jiri Vrany Date: Wed, 27 Mar 2024 09:35:33 +0100 Subject: [PATCH 08/26] fix typo in user template --- flowapp/templates/pages/dashboard_user.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flowapp/templates/pages/dashboard_user.html b/flowapp/templates/pages/dashboard_user.html index 683f5436..f3c777fa 100644 --- a/flowapp/templates/pages/dashboard_user.html +++ b/flowapp/templates/pages/dashboard_user.html @@ -16,7 +16,7 @@

{{ rstate|capitalize }} {{ table_title }} that you can modify

{{ dashboard_table_editable_head }} {{ dashboard_table_editable }} - {{ dashboard_table_foot }}} + {{ dashboard_table_foot }}
From 02b08ff5ebc50603251f2533542f193f30257708 Mon Sep 17 00:00:00 2001 From: Jiri Vrany Date: Thu, 28 Mar 2024 17:13:30 +0100 Subject: [PATCH 09/26] update models and api auth for machinekeys and reaonly keys --- flowapp/models.py | 27 ++++++++++++ flowapp/tests/conftest.py | 47 ++++++++++++++++++--- flowapp/tests/test_api_auth.py | 61 ++++++++++++++++++++++++++++ flowapp/views/api_common.py | 47 ++++++++++++++++++--- flowapp/views/api_v3.py | 3 +- migrations/versions/4af5ae4bae1c_.py | 38 +++++++++++++++++ migrations/versions/67bb6c1b3898_.py | 45 ++++++++++++++++++++ 7 files changed, 256 insertions(+), 12 deletions(-) create mode 100644 flowapp/tests/test_api_auth.py create mode 100644 migrations/versions/4af5ae4bae1c_.py create mode 100644 migrations/versions/67bb6c1b3898_.py diff --git a/flowapp/models.py b/flowapp/models.py index 03c1963e..c274e932 100644 --- a/flowapp/models.py +++ b/flowapp/models.py @@ -34,6 +34,7 @@ class User(db.Model): name = db.Column(db.String(255)) phone = db.Column(db.String(255)) apikeys = db.relationship("ApiKey", back_populates="user", lazy="dynamic") + machineapikeys = db.relationship("MachineApiKey", back_populates="user", lazy="dynamic") role = db.relationship("Role", secondary=user_role, lazy="dynamic", backref="user") organization = db.relationship( @@ -82,9 +83,35 @@ class ApiKey(db.Model): id = db.Column(db.Integer, primary_key=True) machine = db.Column(db.String(255)) key = db.Column(db.String(255)) + readonly = db.Column(db.Boolean, default=False) + expires = db.Column(db.DateTime, nullable=True) + comment = db.Column(db.String(255)) user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) user = db.relationship("User", back_populates="apikeys") + def is_expired(self): + if self.expires is None: + return False # Non-expiring key + else: + return self.expires < datetime.now() + + +class MachineApiKey(db.Model): + id = db.Column(db.Integer, primary_key=True) + machine = db.Column(db.String(255)) + key = db.Column(db.String(255)) + readonly = db.Column(db.Boolean, default=True) + expires = db.Column(db.DateTime, nullable=True) + comment = db.Column(db.String(255)) + user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) + user = db.relationship("User", back_populates="machineapikeys") + + def is_expired(self): + if self.expires is None: + return False # Non-expiring key + else: + return self.expires < datetime.now() + class Role(db.Model): id = db.Column(db.Integer, primary_key=True) diff --git a/flowapp/tests/conftest.py b/flowapp/tests/conftest.py index ed3c378d..03aea5f9 100644 --- a/flowapp/tests/conftest.py +++ b/flowapp/tests/conftest.py @@ -9,6 +9,7 @@ from flowapp import create_app from flowapp import db as _db +from datetime import datetime import flowapp.models TESTDB = "test_project.db" @@ -121,11 +122,6 @@ def db(app, request): print("#: inserting users") flowapp.models.insert_users(users) - model = flowapp.models.ApiKey(machine="127.0.0.1", key="testkey", user_id=1) - - _db.session.add(model) - _db.session.commit() - def teardown(): _db.session.commit() _db.drop_all() @@ -136,12 +132,51 @@ def teardown(): @pytest.fixture(scope="session") -def jwt_token(client, db, request): +def jwt_token(client, app, db, request): """ Get the test_client from the app, for the whole test session. """ + with app.app_context(): + model = flowapp.models.ApiKey(machine="127.0.0.1", key="testkey", user_id=1) + db.session.add(model) + db.session.commit() + print("\n----- GET JWT TEST TOKEN\n") url = "/api/v1/auth/testkey" token = client.get(url) data = json.loads(token.data) return data["token"] + + +@pytest.fixture(scope="session") +def expired_auth_token(client, app, db, request): + """ + Get the test_client from the app, for the whole test session. + """ + test_key = "expired_test_key" + expired_date = datetime.strptime("2019-01-01", "%Y-%m-%d") + with app.app_context(): + model = flowapp.models.ApiKey(machine="127.0.0.1", key=test_key, user_id=1, expires=expired_date) + db.session.add(model) + db.session.commit() + + return test_key + + +@pytest.fixture(scope="session") +def readonly_jwt_token(client, app, db, request): + """ + Get the test_client from the app, for the whole test session. + """ + readonly_key = "readonly-testkey" + with app.app_context(): + model = flowapp.models.ApiKey(machine="127.0.0.1", key=readonly_key, user_id=1, readonly=True) + db.session.add(model) + db.session.commit() + + print("\n----- GET JWT TEST TOKEN\n") + url = "/api/v3/auth" + headers = {"x-api-key": readonly_key} + token = client.get(url, headers=headers) + data = json.loads(token.data) + return data["token"] diff --git a/flowapp/tests/test_api_auth.py b/flowapp/tests/test_api_auth.py new file mode 100644 index 00000000..e784944d --- /dev/null +++ b/flowapp/tests/test_api_auth.py @@ -0,0 +1,61 @@ +# Test for api authorization +import json + + +def test_token(client, jwt_token): + """ + test that token authorization works + """ + req = client.get("/api/v3/test_token", headers={"x-access-token": jwt_token}) + + assert req.status_code == 200 + + +def test_expired_token(client, expired_auth_token): + """ + test that expired token authorization return 401 + """ + req = client.get("/api/v3/auth", headers={"x-api-key": expired_auth_token}) + + assert req.status_code == 401 + + +def test_withnout_token(client): + """ + test that without token authorization return 401 + """ + req = client.get("/api/v3/test_token") + + assert req.status_code == 401 + +def test_readonly_token(client, readonly_jwt_token): + """ + test that readonly flag is set correctly + """ + req = client.get("/api/v3/test_token", headers={"x-access-token": readonly_jwt_token}) + + assert req.status_code == 200 + data = json.loads(req.data) + assert data['readonly'] + + +def test_readonly_token_ipv4_create(client, db, readonly_jwt_token): + """ + test that readonly token can't create ipv4 rule + """ + headers = {"x-access-token": readonly_jwt_token} + + req = client.post( + "/api/v3/rules/ipv4", + headers=headers, + json={ + "action": 2, + "protocol": "tcp", + "source": "147.230.17.117", + "source_mask": 32, + "source_port": "", + "expires": "1444913400", + }, + ) + + assert req.status_code == 403 diff --git a/flowapp/views/api_common.py b/flowapp/views/api_common.py index 515e0c90..9fc3e142 100644 --- a/flowapp/views/api_common.py +++ b/flowapp/views/api_common.py @@ -11,6 +11,7 @@ Flowspec4, Flowspec6, ApiKey, + MachineApiKey, Community, get_user_nets, get_user_actions, @@ -59,7 +60,7 @@ def decorated(*args, **kwargs): except jwt.ExpiredSignatureError: return jsonify({"message": "auth token expired"}), 401 - return f(current_user, *args, **kwargs) + return f(current_user=current_user, *args, **kwargs) return decorated @@ -71,7 +72,20 @@ def authorize(user_key): """ jwt_key = current_app.config.get("JWT_SECRET") + # try normal user key first model = db.session.query(ApiKey).filter_by(key=user_key).first() + # if not found try machine key + if not model: + model = db.session.query(MachineApiKey).filter_by(key=user_key).first() + # if key is not found return 403 + if not model: + return jsonify({"message": "auth token is invalid"}), 403 + + # check if the key is not expired + if model.is_expired(): + return jsonify({"message": "auth token is expired"}), 401 + + # check if the key is not used by different machine if model and ipaddress.ip_address(model.machine) == ipaddress.ip_address( request.remote_addr ): @@ -79,6 +93,7 @@ def authorize(user_key): "user": { "uuid": model.user.uuid, "id": model.user.id, + "readonly": model.readonly, "roles": [role.name for role in model.user.role.all()], "org": [org.name for org in model.user.organization.all()], "role_ids": [role.id for role in model.user.role.all()], @@ -94,6 +109,26 @@ def authorize(user_key): return jsonify({"message": "auth token is invalid"}), 403 +def check_readonly(func): + """ + Check if the token is readonly + Used in api endpoints + """ + @wraps(func) + def decorated_function(*args, **kwargs): + # Access read only flag from first of the args + print("ARGS", args) + print("KWARGS", kwargs) + current_user = kwargs.get("current_user", False) + read_only = current_user.get("readonly", False) + if read_only: + return jsonify({"message": "read only token can't perform this action"}), 403 + return func(*args, **kwargs) + return decorated_function + + +# endpints + def index(current_user, key_map): prefered_tf = ( request.args.get(TIME_FORMAT_ARG) if request.args.get(TIME_FORMAT_ARG) else "" @@ -486,10 +521,12 @@ def token_test_get(current_user): :param rule_id: :return: """ - return ( - jsonify({"message": "token works as expected", "uuid": current_user["uuid"]}), - 200, - ) + my_response = { + "message": "token works as expected", + "uuid": current_user["uuid"], + "readonly": current_user["readonly"], + } + return jsonify(my_response), 200 def get_form_errors(form): diff --git a/flowapp/views/api_v3.py b/flowapp/views/api_v3.py index 094b2a52..35526faf 100644 --- a/flowapp/views/api_v3.py +++ b/flowapp/views/api_v3.py @@ -1,4 +1,4 @@ -from flask import Blueprint, request +from flask import Blueprint, jsonify, request from flowapp import csrf from flowapp.views import api_common @@ -49,6 +49,7 @@ def all_communities(current_user): @api.route("/rules/ipv4", methods=["POST"]) @api_common.token_required +@api_common.check_readonly def create_ipv4(current_user): """ Api method for new IPv4 rule diff --git a/migrations/versions/4af5ae4bae1c_.py b/migrations/versions/4af5ae4bae1c_.py new file mode 100644 index 00000000..15017fb6 --- /dev/null +++ b/migrations/versions/4af5ae4bae1c_.py @@ -0,0 +1,38 @@ +"""empty message + +Revision ID: 4af5ae4bae1c +Revises: 67bb6c1b3898 +Create Date: 2024-03-27 18:19:35.721215 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '4af5ae4bae1c' +down_revision = '67bb6c1b3898' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('api_key', schema=None) as batch_op: + batch_op.add_column(sa.Column('comment', sa.String(length=255), nullable=True)) + + with op.batch_alter_table('machine_api_key', schema=None) as batch_op: + batch_op.add_column(sa.Column('readonly', sa.Boolean(), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('machine_api_key', schema=None) as batch_op: + batch_op.drop_column('readonly') + + with op.batch_alter_table('api_key', schema=None) as batch_op: + batch_op.drop_column('comment') + + # ### end Alembic commands ### diff --git a/migrations/versions/67bb6c1b3898_.py b/migrations/versions/67bb6c1b3898_.py new file mode 100644 index 00000000..ec0d3e08 --- /dev/null +++ b/migrations/versions/67bb6c1b3898_.py @@ -0,0 +1,45 @@ +"""empty message + +Revision ID: 67bb6c1b3898 +Revises: 2bd0e800ab1c +Create Date: 2024-03-27 18:13:10.688958 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '67bb6c1b3898' +down_revision = '2bd0e800ab1c' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('machine_api_key', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('machine', sa.String(length=255), nullable=True), + sa.Column('key', sa.String(length=255), nullable=True), + sa.Column('expires', sa.DateTime(), nullable=True), + sa.Column('comment', sa.String(length=255), nullable=True), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('api_key', schema=None) as batch_op: + batch_op.add_column(sa.Column('readonly', sa.Boolean(), nullable=True)) + batch_op.add_column(sa.Column('expires', sa.DateTime(), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('api_key', schema=None) as batch_op: + batch_op.drop_column('expires') + batch_op.drop_column('readonly') + + op.drop_table('machine_api_key') + # ### end Alembic commands ### From 5f58318862d3e555462878b8afb086c8174e98e2 Mon Sep 17 00:00:00 2001 From: Jiri Vrany Date: Tue, 2 Apr 2024 12:23:01 +0200 Subject: [PATCH 10/26] api v1 and v2 are now deprecated --- flowapp/tests/conftest.py | 9 +- flowapp/tests/test_api_auth.py | 1 + flowapp/tests/test_api_deprecated.py | 28 ++ flowapp/tests/test_api_v1.py | 430 ------------------ .../tests/{test_api_v2.py => test_api_v3.py} | 66 +-- flowapp/tests/test_forms.py | 1 + flowapp/views/api_common.py | 2 +- flowapp/views/api_v1.py | 164 +------ flowapp/views/api_v2.py | 164 +------ flowapp/views/api_v3.py | 7 +- 10 files changed, 97 insertions(+), 775 deletions(-) create mode 100644 flowapp/tests/test_api_deprecated.py delete mode 100644 flowapp/tests/test_api_v1.py rename flowapp/tests/{test_api_v2.py => test_api_v3.py} (88%) diff --git a/flowapp/tests/conftest.py b/flowapp/tests/conftest.py index 03aea5f9..1d5436db 100644 --- a/flowapp/tests/conftest.py +++ b/flowapp/tests/conftest.py @@ -136,14 +136,17 @@ def jwt_token(client, app, db, request): """ Get the test_client from the app, for the whole test session. """ + mkey = "testkey" + with app.app_context(): - model = flowapp.models.ApiKey(machine="127.0.0.1", key="testkey", user_id=1) + model = flowapp.models.ApiKey(machine="127.0.0.1", key=mkey, user_id=1) db.session.add(model) db.session.commit() print("\n----- GET JWT TEST TOKEN\n") - url = "/api/v1/auth/testkey" - token = client.get(url) + url = "/api/v3/auth" + headers = {"x-api-key": mkey} + token = client.get(url, headers=headers) data = json.loads(token.data) return data["token"] diff --git a/flowapp/tests/test_api_auth.py b/flowapp/tests/test_api_auth.py index e784944d..5733346b 100644 --- a/flowapp/tests/test_api_auth.py +++ b/flowapp/tests/test_api_auth.py @@ -28,6 +28,7 @@ def test_withnout_token(client): assert req.status_code == 401 + def test_readonly_token(client, readonly_jwt_token): """ test that readonly flag is set correctly diff --git a/flowapp/tests/test_api_deprecated.py b/flowapp/tests/test_api_deprecated.py new file mode 100644 index 00000000..fca94148 --- /dev/null +++ b/flowapp/tests/test_api_deprecated.py @@ -0,0 +1,28 @@ +V_PREFIX = "/api/v1" + + +def test_token(client, jwt_token): + """ + test that token authorization works + """ + req = client.get(f"{V_PREFIX}/test_token", headers={"x-access-token": jwt_token}) + + assert req.status_code == 400 + + +def test_withnout_token(client): + """ + test that without token authorization return 401 + """ + req = client.get(f"{V_PREFIX}/test_token") + + assert req.status_code == 400 + + +def test_rules(client, db, jwt_token): + """ + test that there is one ipv4 rule created in the first test + """ + req = client.get(f"{V_PREFIX}/rules", headers={"x-access-token": jwt_token}) + + assert req.status_code == 400 diff --git a/flowapp/tests/test_api_v1.py b/flowapp/tests/test_api_v1.py deleted file mode 100644 index 1fd27b1f..00000000 --- a/flowapp/tests/test_api_v1.py +++ /dev/null @@ -1,430 +0,0 @@ -import json -from flowapp.output import announce_route - - -def test_token(client, jwt_token): - """ - test that token authorization works - """ - req = client.get("/api/v1/test_token", headers={"x-access-token": jwt_token}) - - assert req.status_code == 200 - - -def test_withnout_token(client): - """ - test that without token authorization return 401 - """ - req = client.get("/api/v1/test_token") - - assert req.status_code == 401 - - -def test_list_actions(client, db, jwt_token): - """ - test that endpoint returns all action in db - """ - req = client.get("/api/v1/actions", headers={"x-access-token": jwt_token}) - - assert req.status_code == 200 - data = json.loads(req.data) - assert len(data) == 4 - - -def test_list_communities(client, db, jwt_token): - """ - test that endpoint returns all action in db - """ - req = client.get("/api/v1/communities", headers={"x-access-token": jwt_token}) - - assert req.status_code == 200 - data = json.loads(req.data) - assert len(data) == 3 - - -def test_create_v4rule(client, db, jwt_token): - """ - test that creating with valid data returns 201 - """ - req = client.post( - "/api/v1/rules/ipv4", - headers={"x-access-token": jwt_token}, - json={ - "action": 2, - "protocol": "tcp", - "source": "147.230.17.17", - "source_mask": 32, - "source_port": "", - "expires": "10/15/2050 14:46", - }, - ) - - assert req.status_code == 201 - data = json.loads(req.data) - assert data["rule"] - assert data["rule"]["id"] == 1 - assert data["rule"]["user"] == "jiri.vrany@tul.cz" - - -def test_delete_v4rule(client, db, jwt_token): - """ - test that creating with valid data returns 201 - that time in the past creates expired rule (state 2) - and that the rule deletion works as expected - """ - req = client.post( - "/api/v1/rules/ipv4", - headers={"x-access-token": jwt_token}, - json={ - "action": 2, - "protocol": "tcp", - "source": "147.230.17.12", - "source_mask": 32, - "source_port": "", - "expires": "10/15/2015 14:46", - }, - ) - - assert req.status_code == 201 - data = json.loads(req.data) - assert data["rule"]["id"] == 2 - assert data["rule"]["rstate"] == "withdrawed rule" - - req2 = client.delete( - "/api/v1/rules/ipv4/{}".format(data["rule"]["id"]), - headers={"x-access-token": jwt_token}, - ) - assert req2.status_code == 201 - - -def test_create_rtbh_rule(client, db, jwt_token): - """ - test that creating with valid data returns 201 - """ - req = client.post( - "/api/v1/rules/rtbh", - headers={"x-access-token": jwt_token}, - json={ - "community": 1, - "ipv4": "147.230.17.17", - "ipv4_mask": 32, - "expires": "10/25/2050 14:46", - }, - ) - data = json.loads(req.data) - assert req.status_code == 201 - assert data["rule"] - assert data["rule"]["id"] == 1 - assert data["rule"]["user"] == "jiri.vrany@tul.cz" - - -def test_delete_rtbh_rule(client, db, jwt_token): - """ - test that creating with valid data returns 201 - """ - req = client.post( - "/api/v1/rules/rtbh", - headers={"x-access-token": jwt_token}, - json={ - "community": 2, - "ipv4": "147.230.17.177", - "ipv4_mask": 32, - "expires": "10/25/2050 14:46", - }, - ) - - assert req.status_code == 201 - data = json.loads(req.data) - assert data["rule"]["id"] == 2 - req2 = client.delete( - "/api/v1/rules/rtbh/{}".format(data["rule"]["id"]), - headers={"x-access-token": jwt_token}, - ) - assert req2.status_code == 201 - - -def test_create_v6rule(client, db, jwt_token): - """ - test that creating with valid data returns 201 - """ - req = client.post( - "/api/v1/rules/ipv6", - headers={"x-access-token": jwt_token}, - json={ - "action": 3, - "next_header": "tcp", - "source": "2001:718:1C01:1111::", - "source_mask": 64, - "source_port": "", - "expires": "10/25/2050 14:46", - }, - ) - data = json.loads(req.data) - assert req.status_code == 201 - assert data["rule"] - assert data["rule"]["id"] == "1" - assert data["rule"]["user"] == "jiri.vrany@tul.cz" - - -def test_validation_v4rule(client, db, jwt_token): - """ - test that creating with invalid data returns 400 and errors - """ - req = client.post( - "/api/v1/rules/ipv4", - headers={"x-access-token": jwt_token}, - json={ - "action": 2, - "dest": "200.200.200.32", - "dest_mask": 16, - "protocol": "tcp", - "source": "1.1.1.1", - "source_mask": 32, - "source_port": "", - "expires": "10/15/2050 14:46", - }, - ) - - assert req.status_code == 400 - data = json.loads(req.data) - assert len(data["validation_errors"]) > 0 - assert sorted(data["validation_errors"].keys()) == sorted(["dest", "source"]) - assert len(data["validation_errors"]["dest"]) == 2 - assert data["validation_errors"]["dest"][0].startswith("This is not") - assert data["validation_errors"]["dest"][1].startswith("Source or des") - assert len(data["validation_errors"]["source"]) == 1 - assert data["validation_errors"]["source"][0].startswith("Source or des") - - -def test_validation_v4rule_fragment(client, db, jwt_token): - """ - test that creating with invalid fragment values returns 400 and errors - """ - bad_string = "bad-and-ugly" - req = client.post( - "/api/v1/rules/ipv4", - headers={"x-access-token": jwt_token}, - json={ - "action": 2, - "protocol": "tcp", - "source": "147.230.17.12", - "source_mask": 32, - "source_port": "", - "expires": "10/15/2050 14:46", - "fragment": bad_string, - }, - ) - - assert req.status_code == 400 - data = json.loads(req.data) - assert len(data["validation_errors"]) > 0 - assert "fragment" in data["validation_errors"].keys() - assert bad_string in data["validation_errors"]["fragment"][0] - - -def test_all_validation_errors(client, db, jwt_token): - """ - test that creating with invalid data returns 400 and errors - """ - req = client.post( - "/api/v1/rules/ipv4", headers={"x-access-token": jwt_token}, json={"action": 2} - ) - data = json.loads(req.data) - assert req.status_code == 400 - - -def test_validate_v6rule(client, db, jwt_token): - """ - test that creating with invalid data returns 400 and errors - """ - req = client.post( - "/api/v1/rules/ipv6", - headers={"x-access-token": jwt_token}, - json={ - "action": 32, - "next_header": "abc", - "source": "2011:78:1C01:1111::", - "source_mask": 64, - "source_port": "", - "expires": "10/25/2050 14:46", - }, - ) - data = json.loads(req.data) - assert req.status_code == 400 - assert len(data["validation_errors"]) > 0 - assert sorted(data["validation_errors"].keys()) == sorted( - ["action", "next_header", "dest", "source"] - ) - # assert data['validation_errors'][0].startswith('Error in the Action') - # assert data['validation_errors'][1].startswith('Error in the Source') - # assert data['validation_errors'][2].startswith('Error in the Next Header') - - -def test_rules(client, db, jwt_token): - """ - test that there is one ipv4 rule created in the first test - """ - req = client.get("/api/v1/rules", headers={"x-access-token": jwt_token}) - - assert req.status_code == 200 - - data = json.loads(req.data) - assert len(data["ipv4_rules"]) == 1 - assert len(data["ipv6_rules"]) == 1 - - -def test_timestamp_param(client, db, jwt_token): - """ - test that url param for time format works as expected - """ - req = client.get( - "/api/v1/rules?time_format=timestamp", headers={"x-access-token": jwt_token} - ) - - assert req.status_code == 200 - - data = json.loads(req.data) - assert data["ipv4_rules"][0]["expires"] == 2549451000 - assert data["ipv6_rules"][0]["expires"] == 2550315000 - - -def test_update_existing_v4rule_with_timestamp(client, db, jwt_token): - """ - test that update with different data passes - """ - req = client.post( - "/api/v1/rules/ipv4", - headers={"x-access-token": jwt_token}, - json={ - "action": 2, - "protocol": "tcp", - "source": "147.230.17.17", - "source_mask": 32, - "source_port": "", - "expires": "1444913400", - }, - ) - - assert req.status_code == 201 - data = json.loads(req.data) - assert data["rule"] - assert data["rule"]["id"] == 1 - assert data["rule"]["user"] == "jiri.vrany@tul.cz" - assert data["rule"]["expires"] == 1444913400 - - -def test_create_v4rule_with_timestamp(client, db, jwt_token): - """ - test that creating with valid data returns 201 - """ - req = client.post( - "/api/v1/rules/ipv4", - headers={"x-access-token": jwt_token}, - json={ - "action": 2, - "protocol": "tcp", - "source": "147.230.17.117", - "source_mask": 32, - "source_port": "", - "expires": "1444913400", - }, - ) - - assert req.status_code == 201 - data = json.loads(req.data) - assert data["rule"] - assert data["rule"]["id"] == 2 - assert data["rule"]["user"] == "jiri.vrany@tul.cz" - assert data["rule"]["expires"] == 1444913400 - - -def test_update_existing_v6rule_with_timestamp(client, db, jwt_token): - """ - test that update with different data passes - """ - req = client.post( - "/api/v1/rules/ipv6", - headers={"x-access-token": jwt_token}, - json={ - "action": 3, - "next_header": "tcp", - "source": "2001:718:1C01:1111::", - "source_mask": 64, - "source_port": "", - "expires": "1444913400", - }, - ) - data = json.loads(req.data) - assert req.status_code == 201 - assert data["rule"] - assert data["rule"]["id"] == "1" - assert data["rule"]["user"] == "jiri.vrany@tul.cz" - assert data["rule"]["expires"] == 1444913400 - - -def test_create_v6rule_with_timestamp(client, db, jwt_token): - """ - test that creating with valid data returns 201 - """ - req = client.post( - "/api/v1/rules/ipv6", - headers={"x-access-token": jwt_token}, - json={ - "action": 2, - "next_header": "udp", - "source": "2001:718:1C01:1111::", - "source_mask": 64, - "source_port": "", - "expires": "1444913400", - }, - ) - data = json.loads(req.data) - assert req.status_code == 201 - assert data["rule"] - assert data["rule"]["id"] == "2" - assert data["rule"]["user"] == "jiri.vrany@tul.cz" - assert data["rule"]["expires"] == 1444913400 - - -def test_update_existing_rtbh_rule_with_timestamp(client, db, jwt_token): - """ - test that update with different data passes - """ - req = client.post( - "/api/v1/rules/rtbh", - headers={"x-access-token": jwt_token}, - json={ - "community": 1, - "ipv4": "147.230.17.17", - "ipv4_mask": 32, - "expires": "1444913400", - }, - ) - data = json.loads(req.data) - assert req.status_code == 201 - assert data["rule"] - assert data["rule"]["id"] == 1 - assert data["rule"]["user"] == "jiri.vrany@tul.cz" - assert data["rule"]["expires"] == 1444913400 - - -def test_create_rtbh_rule_with_timestamp(client, db, jwt_token): - """ - test that creating with valid data returns 201 - """ - req = client.post( - "/api/v1/rules/rtbh", - headers={"x-access-token": jwt_token}, - json={ - "community": 1, - "ipv4": "147.230.17.117", - "ipv4_mask": 32, - "expires": "1444913400", - }, - ) - data = json.loads(req.data) - assert req.status_code == 201 - assert data["rule"] - assert data["rule"]["id"] == 2 - assert data["rule"]["user"] == "jiri.vrany@tul.cz" - assert data["rule"]["expires"] == 1444913400 diff --git a/flowapp/tests/test_api_v2.py b/flowapp/tests/test_api_v3.py similarity index 88% rename from flowapp/tests/test_api_v2.py rename to flowapp/tests/test_api_v3.py index 0ffb54f8..75abb73c 100644 --- a/flowapp/tests/test_api_v2.py +++ b/flowapp/tests/test_api_v3.py @@ -1,11 +1,13 @@ import json +V_PREFIX = "/api/v3" + def test_token(client, jwt_token): """ test that token authorization works """ - req = client.get("/api/v2/test_token", headers={"x-access-token": jwt_token}) + req = client.get(f"{V_PREFIX}/test_token", headers={"x-access-token": jwt_token}) assert req.status_code == 200 @@ -14,7 +16,7 @@ def test_withnout_token(client): """ test that without token authorization return 401 """ - req = client.get("/api/v2/test_token") + req = client.get(f"{V_PREFIX}/test_token") assert req.status_code == 401 @@ -23,7 +25,7 @@ def test_list_actions(client, db, jwt_token): """ test that endpoint returns all action in db """ - req = client.get("/api/v2/actions", headers={"x-access-token": jwt_token}) + req = client.get(f"{V_PREFIX}/actions", headers={"x-access-token": jwt_token}) assert req.status_code == 200 data = json.loads(req.data) @@ -34,7 +36,7 @@ def test_list_communities(client, db, jwt_token): """ test that endpoint returns all action in db """ - req = client.get("/api/v2/communities", headers={"x-access-token": jwt_token}) + req = client.get(f"{V_PREFIX}/communities", headers={"x-access-token": jwt_token}) assert req.status_code == 200 data = json.loads(req.data) @@ -46,7 +48,7 @@ def test_create_v4rule(client, db, jwt_token): test that creating with valid data returns 201 """ req = client.post( - "/api/v2/rules/ipv4", + f"{V_PREFIX}/rules/ipv4", headers={"x-access-token": jwt_token}, json={ "action": 2, @@ -62,7 +64,7 @@ def test_create_v4rule(client, db, jwt_token): assert req.status_code == 201 data = json.loads(req.data) assert data["rule"] - assert data["rule"]["id"] == 3 + assert data["rule"]["id"] == 1 assert data["rule"]["user"] == "jiri.vrany@tul.cz" @@ -73,7 +75,7 @@ def test_delete_v4rule(client, db, jwt_token): and that the rule deletion works as expected """ req = client.post( - "/api/v2/rules/ipv4", + f"{V_PREFIX}/rules/ipv4", headers={"x-access-token": jwt_token}, json={ "action": 2, @@ -87,11 +89,11 @@ def test_delete_v4rule(client, db, jwt_token): assert req.status_code == 201 data = json.loads(req.data) - assert data["rule"]["id"] == 4 + assert data["rule"]["id"] == 2 assert data["rule"]["rstate"] == "withdrawed rule" req2 = client.delete( - "/api/v2/rules/ipv4/{}".format(data["rule"]["id"]), + f'{V_PREFIX}/rules/ipv4/{data["rule"]["id"]}', headers={"x-access-token": jwt_token}, ) assert req2.status_code == 201 @@ -102,7 +104,7 @@ def test_create_rtbh_rule(client, db, jwt_token): test that creating with valid data returns 201 """ req = client.post( - "/api/v2/rules/rtbh", + f"{V_PREFIX}/rules/rtbh", headers={"x-access-token": jwt_token}, json={ "community": 1, @@ -123,7 +125,7 @@ def test_delete_rtbh_rule(client, db, jwt_token): test that creating with valid data returns 201 """ req = client.post( - "/api/v2/rules/rtbh", + f"{V_PREFIX}/rules/rtbh", headers={"x-access-token": jwt_token}, json={ "community": 2, @@ -135,9 +137,9 @@ def test_delete_rtbh_rule(client, db, jwt_token): assert req.status_code == 201 data = json.loads(req.data) - assert data["rule"]["id"] == 3 + assert data["rule"]["id"] == 2 req2 = client.delete( - "/api/v2/rules/rtbh/{}".format(data["rule"]["id"]), + f'{V_PREFIX}/rules/rtbh/{data["rule"]["id"]}', headers={"x-access-token": jwt_token}, ) assert req2.status_code == 201 @@ -148,7 +150,7 @@ def test_validation_rtbh_rule(client, db, jwt_token): test that creating with invalid data returns 400 and errors """ req = client.post( - "/api/v2/rules/rtbh", + f"{V_PREFIX}/rules/rtbh", headers={"x-access-token": jwt_token}, json={ "community": 1, @@ -172,7 +174,7 @@ def test_create_v6rule(client, db, jwt_token): test that creating with valid data returns 201 """ req = client.post( - "/api/v2/rules/ipv6", + f"{V_PREFIX}/rules/ipv6", headers={"x-access-token": jwt_token}, json={ "action": 3, @@ -195,7 +197,7 @@ def test_validation_v4rule(client, db, jwt_token): test that creating with invalid data returns 400 and errors """ req = client.post( - "/api/v2/rules/ipv4", + f"{V_PREFIX}/rules/ipv4", headers={"x-access-token": jwt_token}, json={ "action": 2, @@ -225,7 +227,7 @@ def test_all_validation_errors(client, db, jwt_token): test that creating with invalid data returns 400 and errors """ req = client.post( - "/api/v2/rules/ipv4", headers={"x-access-token": jwt_token}, json={"action": 2} + f"{V_PREFIX}/rules/ipv4", headers={"x-access-token": jwt_token}, json={"action": 2} ) data = json.loads(req.data) assert req.status_code == 400 @@ -236,7 +238,7 @@ def test_validate_v6rule(client, db, jwt_token): test that creating with invalid data returns 400 and errors """ req = client.post( - "/api/v2/rules/ipv6", + f"{V_PREFIX}/rules/ipv6", headers={"x-access-token": jwt_token}, json={ "action": 32, @@ -262,13 +264,13 @@ def test_rules(client, db, jwt_token): """ test that there is one ipv4 rule created in the first test """ - req = client.get("/api/v2/rules", headers={"x-access-token": jwt_token}) + req = client.get(f"{V_PREFIX}/rules", headers={"x-access-token": jwt_token}) assert req.status_code == 200 data = json.loads(req.data) - assert len(data["flowspec_ipv4_rw"]) == 3 - assert len(data["flowspec_ipv6_rw"]) == 2 + assert len(data["flowspec_ipv4_rw"]) == 1 + assert len(data["flowspec_ipv6_rw"]) == 1 def test_timestamp_param(client, db, jwt_token): @@ -276,7 +278,7 @@ def test_timestamp_param(client, db, jwt_token): test that url param for time format works as expected """ req = client.get( - "/api/v2/rules?time_format=timestamp", headers={"x-access-token": jwt_token} + f"{V_PREFIX}/rules?time_format=timestamp", headers={"x-access-token": jwt_token} ) assert req.status_code == 200 @@ -291,7 +293,7 @@ def test_update_existing_v4rule_with_timestamp(client, db, jwt_token): test that update with different data passes """ req = client.post( - "/api/v2/rules/ipv4", + f"{V_PREFIX}/rules/ipv4", headers={"x-access-token": jwt_token}, json={ "action": 2, @@ -306,7 +308,7 @@ def test_update_existing_v4rule_with_timestamp(client, db, jwt_token): assert req.status_code == 201 data = json.loads(req.data) assert data["rule"] - assert data["rule"]["id"] == 1 + assert data["rule"]["id"] == 2 assert data["rule"]["user"] == "jiri.vrany@tul.cz" assert data["rule"]["expires"] == 1444913400 @@ -316,7 +318,7 @@ def test_create_v4rule_with_timestamp(client, db, jwt_token): test that creating with valid data returns 201 """ req = client.post( - "/api/v2/rules/ipv4", + f"{V_PREFIX}/rules/ipv4", headers={"x-access-token": jwt_token}, json={ "action": 2, @@ -331,7 +333,7 @@ def test_create_v4rule_with_timestamp(client, db, jwt_token): assert req.status_code == 201 data = json.loads(req.data) assert data["rule"] - assert data["rule"]["id"] == 4 + assert data["rule"]["id"] == 3 assert data["rule"]["user"] == "jiri.vrany@tul.cz" assert data["rule"]["expires"] == 1444913400 @@ -341,7 +343,7 @@ def test_update_existing_v6rule_with_timestamp(client, db, jwt_token): test that update with different data passes """ req = client.post( - "/api/v2/rules/ipv6", + f"{V_PREFIX}/rules/ipv6", headers={"x-access-token": jwt_token}, json={ "action": 3, @@ -365,7 +367,7 @@ def test_create_v6rule_with_timestamp(client, db, jwt_token): test that creating with valid data returns 201 """ req = client.post( - "/api/v2/rules/ipv6", + f"{V_PREFIX}/rules/ipv6", headers={"x-access-token": jwt_token}, json={ "action": 2, @@ -379,7 +381,7 @@ def test_create_v6rule_with_timestamp(client, db, jwt_token): data = json.loads(req.data) assert req.status_code == 201 assert data["rule"] - assert data["rule"]["id"] == "3" + assert data["rule"]["id"] == "2" assert data["rule"]["user"] == "jiri.vrany@tul.cz" assert data["rule"]["expires"] == 1444913400 @@ -389,7 +391,7 @@ def test_update_existing_rtbh_rule_with_timestamp(client, db, jwt_token): test that update with different data passes """ req = client.post( - "/api/v2/rules/rtbh", + f"{V_PREFIX}/rules/rtbh", headers={"x-access-token": jwt_token}, json={ "community": 1, @@ -411,7 +413,7 @@ def test_create_rtbh_rule_with_timestamp(client, db, jwt_token): test that creating with valid data returns 201 """ req = client.post( - "/api/v2/rules/rtbh", + f"{V_PREFIX}/rules/rtbh", headers={"x-access-token": jwt_token}, json={ "community": 1, @@ -423,6 +425,6 @@ def test_create_rtbh_rule_with_timestamp(client, db, jwt_token): data = json.loads(req.data) assert req.status_code == 201 assert data["rule"] - assert data["rule"]["id"] == 3 + assert data["rule"]["id"] == 2 assert data["rule"]["user"] == "jiri.vrany@tul.cz" assert data["rule"]["expires"] == 1444913400 diff --git a/flowapp/tests/test_forms.py b/flowapp/tests/test_forms.py index 4418eb6c..481354c9 100644 --- a/flowapp/tests/test_forms.py +++ b/flowapp/tests/test_forms.py @@ -4,6 +4,7 @@ @pytest.fixture() def ip_form(field_class): + form = flowapp.forms.IPForm() form.source = field_class() form.dest = field_class() diff --git a/flowapp/views/api_common.py b/flowapp/views/api_common.py index 9fc3e142..163449ce 100644 --- a/flowapp/views/api_common.py +++ b/flowapp/views/api_common.py @@ -490,7 +490,7 @@ def delete_rule(current_user, rule_id, model_name, route_model, rule_type): :param route_model: :return: """ - model = db.session.query(model_name).get(rule_id) + model = db.session.get(model_name, rule_id) if model: if check_access_rights(current_user, model.user_id): # withdraw route diff --git a/flowapp/views/api_v1.py b/flowapp/views/api_v1.py index 51e4cdc9..1dab2c3d 100644 --- a/flowapp/views/api_v1.py +++ b/flowapp/views/api_v1.py @@ -1,156 +1,12 @@ -from flask import Blueprint -from flowapp import csrf -from flowapp.views import api_common +from flask import Blueprint, jsonify -api = Blueprint("api_v1", __name__, template_folder="templates") - - -@api.route("/auth/", methods=["GET"]) -def authorize(user_key): - return api_common.authorize(user_key) - - -@api.route("/rules") -@api_common.token_required -def index(current_user): - key_map = { - "ipv4_rules": "ipv4_rules", - "ipv6_rules": "ipv6_rules", - "rtbh_rules": "rtbh_rules", - "ipv4_rules_readonly": "ipv4_rules_readonly", - "ipv6_rules_readonly": "ipv6_rules_readonly", - "rtbh_rules_readonly": "rtbh_rules_readonly", - } - return api_common.index(current_user, key_map) - - -@api.route("/actions") -@api_common.token_required -def all_actions(current_user): - """ - Returns Actions allowed for current user - :param current_user: - :return: json response - """ - return api_common.all_actions(current_user) - - -@api.route("/communities") -@api_common.token_required -def all_communities(current_user): - """ - Returns RTHB communites allowed for current user - :param current_user: - :return: json response - """ - return api_common.all_communities(current_user) - - -@api.route("/rules/ipv4", methods=["POST"]) -@api_common.token_required -def create_ipv4(current_user): - """ - Api method for new IPv4 rule - :param data: parsed json request - :param current_user: data from jwt token - :return: json response - """ - return api_common.create_ipv4(current_user) - - -@api.route("/rules/ipv6", methods=["POST"]) -@csrf.exempt -@api_common.token_required -def create_ipv6(current_user): - """ - Create new IPv6 rule - :param data: parsed json request - :param current_user: data from jwt token - :return: - """ - return api_common.create_ipv6(current_user) - - -@api.route("/rules/rtbh", methods=["POST"]) -@csrf.exempt -@api_common.token_required -def create_rtbh(current_user): - return api_common.create_rtbh(current_user) - -@api.route("/rules/ipv4/", methods=["GET"]) -@api_common.token_required -def ipv4_rule_get(current_user, rule_id): - """ - Return IPv4 rule - :param current_user: - :param rule_id: - :return: - """ - return api_common.ipv4_rule_get(current_user) - - -@api.route("/rules/ipv6/", methods=["GET"]) -@api_common.token_required -def ipv6_rule_get(current_user, rule_id): - """ - Return IPv6 rule - :param current_user: - :param rule_id: - :return: - """ - return api_common.ipv6_rule_get(current_user) - - -@api.route("/rules/rtbh/", methods=["GET"]) -@api_common.token_required -def rtbh_rule_get(current_user, rule_id): - """ - Return RTBH rule - :param current_user: - :param rule_id: - :return: - """ - return api_common.rtbh_rule_get(current_user) - - -@api.route("/rules/ipv4/", methods=["DELETE"]) -@api_common.token_required -def delete_v4_rule(current_user, rule_id): - """ - Delete rule with given id and type - :param rule_id: integer - rule id - """ - return api_common.delete_v4_rule(current_user, rule_id) - - -@api.route("/rules/ipv6/", methods=["DELETE"]) -@api_common.token_required -def delete_v6_rule(current_user, rule_id): - """ - Delete rule with given id and type - :param rule_id: integer - rule id - """ - return api_common.delete_v6_rule(current_user, rule_id) - - -@api.route("/rules/rtbh/", methods=["DELETE"]) -@api_common.token_required -def delete_rtbh_rule(current_user, rule_id): - """ - Delete rule with given id and type - :param rule_id: integer - rule id - """ - return api_common.delete_rtbh_rule(current_user, rule_id) - - -@api.route("/test_token", methods=["GET"]) -@api_common.token_required -def token_test_get(current_user): - """ - Return IPv4 rule - :param current_user: - :param rule_id: - :return: - """ - return api_common.token_test_get(current_user) +api = Blueprint("api_v1", __name__, template_folder="templates") +METHODS = ["GET", "POST", "PUT", "DELETE"] + +@api.route("/", defaults={"path": ""}, methods=METHODS) +@api.route("/", methods=METHODS) +def deprecated_warning(path): + """Catch all routes and return a deprecated warning message.""" + message = "Warning: This API version is deprecated. Please use /api/v3/ instead." + return jsonify({"message": message}), 400 diff --git a/flowapp/views/api_v2.py b/flowapp/views/api_v2.py index 561ba36e..12d99b21 100644 --- a/flowapp/views/api_v2.py +++ b/flowapp/views/api_v2.py @@ -1,156 +1,12 @@ -from flask import Blueprint -from flowapp import csrf -from flowapp.views import api_common +from flask import Blueprint, jsonify -api = Blueprint("api_v2", __name__, template_folder="templates") - - -@api.route("/auth/", methods=["GET"]) -def authorize(user_key): - return api_common.authorize(user_key) - - -@api.route("/rules") -@api_common.token_required -def index(current_user): - key_map = { - "ipv4_rules": "flowspec_ipv4_rw", - "ipv6_rules": "flowspec_ipv6_rw", - "rtbh_rules": "rtbh_any_rw", - "ipv4_rules_readonly": "flowspec_ipv4_ro", - "ipv6_rules_readonly": "flowspec_ipv6_ro", - "rtbh_rules_readonly": "rtbh_any_ro", - } - return api_common.index(current_user, key_map) - - -@api.route("/actions") -@api_common.token_required -def all_actions(current_user): - """ - Returns Actions allowed for current user - :param current_user: - :return: json response - """ - return api_common.all_actions(current_user) - - -@api.route("/communities") -@api_common.token_required -def all_communities(current_user): - """ - Returns RTHB communites allowed for current user - :param current_user: - :return: json response - """ - return api_common.all_communities(current_user) - - -@api.route("/rules/ipv4", methods=["POST"]) -@api_common.token_required -def create_ipv4(current_user): - """ - Api method for new IPv4 rule - :param data: parsed json request - :param current_user: data from jwt token - :return: json response - """ - return api_common.create_ipv4(current_user) - - -@api.route("/rules/ipv6", methods=["POST"]) -@csrf.exempt -@api_common.token_required -def create_ipv6(current_user): - """ - Create new IPv6 rule - :param data: parsed json request - :param current_user: data from jwt token - :return: - """ - return api_common.create_ipv6(current_user) - - -@api.route("/rules/rtbh", methods=["POST"]) -@csrf.exempt -@api_common.token_required -def create_rtbh(current_user): - return api_common.create_rtbh(current_user) - -@api.route("/rules/ipv4/", methods=["GET"]) -@api_common.token_required -def ipv4_rule_get(current_user, rule_id): - """ - Return IPv4 rule - :param current_user: - :param rule_id: - :return: - """ - return api_common.ipv4_rule_get(current_user) - - -@api.route("/rules/ipv6/", methods=["GET"]) -@api_common.token_required -def ipv6_rule_get(current_user, rule_id): - """ - Return IPv6 rule - :param current_user: - :param rule_id: - :return: - """ - return api_common.ipv6_rule_get(current_user) - - -@api.route("/rules/rtbh/", methods=["GET"]) -@api_common.token_required -def rtbh_rule_get(current_user, rule_id): - """ - Return RTBH rule - :param current_user: - :param rule_id: - :return: - """ - return api_common.rtbh_rule_get(current_user) - - -@api.route("/rules/ipv4/", methods=["DELETE"]) -@api_common.token_required -def delete_v4_rule(current_user, rule_id): - """ - Delete rule with given id and type - :param rule_id: integer - rule id - """ - return api_common.delete_v4_rule(current_user, rule_id) - - -@api.route("/rules/ipv6/", methods=["DELETE"]) -@api_common.token_required -def delete_v6_rule(current_user, rule_id): - """ - Delete rule with given id and type - :param rule_id: integer - rule id - """ - return api_common.delete_v6_rule(current_user, rule_id) - - -@api.route("/rules/rtbh/", methods=["DELETE"]) -@api_common.token_required -def delete_rtbh_rule(current_user, rule_id): - """ - Delete rule with given id and type - :param rule_id: integer - rule id - """ - return api_common.delete_rtbh_rule(current_user, rule_id) - - -@api.route("/test_token", methods=["GET"]) -@api_common.token_required -def token_test_get(current_user): - """ - Return IPv4 rule - :param current_user: - :param rule_id: - :return: - """ - return api_common.token_test_get(current_user) +api = Blueprint("api_v2", __name__, template_folder="templates") +METHODS = ["GET", "POST", "PUT", "DELETE"] + +@api.route("/", defaults={"path": ""}, methods=METHODS) +@api.route("/", methods=METHODS) +def deprecated_warning(path): + """Catch all routes and return a deprecated warning message.""" + message = "Warning: This API version is deprecated. Please use /api/v3/ instead." + return jsonify({"message": message}), 400 diff --git a/flowapp/views/api_v3.py b/flowapp/views/api_v3.py index 35526faf..10c7fcf0 100644 --- a/flowapp/views/api_v3.py +++ b/flowapp/views/api_v3.py @@ -1,4 +1,4 @@ -from flask import Blueprint, jsonify, request +from flask import Blueprint, request from flowapp import csrf from flowapp.views import api_common @@ -63,6 +63,7 @@ def create_ipv4(current_user): @api.route("/rules/ipv6", methods=["POST"]) @csrf.exempt @api_common.token_required +@api_common.check_readonly def create_ipv6(current_user): """ Create new IPv6 rule @@ -76,6 +77,7 @@ def create_ipv6(current_user): @api.route("/rules/rtbh", methods=["POST"]) @csrf.exempt @api_common.token_required +@api_common.check_readonly def create_rtbh(current_user): return api_common.create_rtbh(current_user) @@ -118,6 +120,7 @@ def rtbh_rule_get(current_user, rule_id): @api.route("/rules/ipv4/", methods=["DELETE"]) @api_common.token_required +@api_common.check_readonly def delete_v4_rule(current_user, rule_id): """ Delete rule with given id and type @@ -128,6 +131,7 @@ def delete_v4_rule(current_user, rule_id): @api.route("/rules/ipv6/", methods=["DELETE"]) @api_common.token_required +@api_common.check_readonly def delete_v6_rule(current_user, rule_id): """ Delete rule with given id and type @@ -138,6 +142,7 @@ def delete_v6_rule(current_user, rule_id): @api.route("/rules/rtbh/", methods=["DELETE"]) @api_common.token_required +@api_common.check_readonly def delete_rtbh_rule(current_user, rule_id): """ Delete rule with given id and type From 382a83e81d564566dd8e917003691087900d4e9e Mon Sep 17 00:00:00 2001 From: Jiri Vrany Date: Tue, 2 Apr 2024 14:40:52 +0200 Subject: [PATCH 11/26] updated user apikey form --- flowapp/forms.py | 18 ++++++++++++++++- flowapp/templates/forms/api_key.html | 29 ++++++++++++++++++---------- flowapp/templates/forms/macros.html | 2 +- flowapp/templates/pages/api_key.html | 26 +++++++++++++++++++++---- flowapp/views/api_keys.py | 9 ++++++++- 5 files changed, 67 insertions(+), 17 deletions(-) diff --git a/flowapp/forms.py b/flowapp/forms.py index 16bdc0f7..3cf35552 100644 --- a/flowapp/forms.py +++ b/flowapp/forms.py @@ -55,12 +55,17 @@ class MultiFormatDateTimeLocalField(DateTimeField): def __init__(self, *args, **kwargs): kwargs.setdefault("format", "%Y-%m-%dT%H:%M") + self.unlimited = kwargs.pop('unlimited', False) self.pref_format = None super().__init__(*args, **kwargs) def process_formdata(self, valuelist): if not valuelist: - return + return None + # with unlimited field we do not need to parse the empty value + if self.unlimited and len(valuelist) == 1 and len(valuelist[0]) == 0: + self.data = None + return None date_str = " ".join((str(val) for val in valuelist)) result, pref_format = parse_api_time(date_str) @@ -119,6 +124,17 @@ class ApiKeyForm(FlaskForm): validators=[DataRequired(), IPAddress(message="provide valid IP address")], ) + comment = TextAreaField( + "Your comment for this key", validators=[Optional(), Length(max=255)] + ) + + expires = MultiFormatDateTimeLocalField( + "Key expiration. Leave blank for non expring key (not-recomended).", + format=FORM_TIME_PATTERN, validators=[Optional()], unlimited=True + ) + + readonly = BooleanField("Read only key", default=False) + key = HiddenField("GeneratedKey") diff --git a/flowapp/templates/forms/api_key.html b/flowapp/templates/forms/api_key.html index 9d8901cb..0500a39c 100644 --- a/flowapp/templates/forms/api_key.html +++ b/flowapp/templates/forms/api_key.html @@ -1,25 +1,34 @@ {% extends 'layouts/default.html' %} -{% from 'forms/macros.html' import render_field %} +{% from 'forms/macros.html' import render_field, render_checkbox_field %} {% block title %}Add New Machine with ApiKey{% endblock %} {% block content %}

Add new ApiKey for your machine

+ +
+ +
+
ApiKey: {{ generated_key }}
+
+
{{ form.hidden_tag() if form.hidden_tag }}
-
+
{{ render_field(form.machine) }}
-
- -
-
- ApiKey for this machine: +
+ {{ render_checkbox_field(form.readonly) }}
-
- {{ generated_key }} +
+ {{ render_field(form.expires) }}
+
+
- +
+
+ {{ render_field(form.comment) }} +
diff --git a/flowapp/templates/forms/macros.html b/flowapp/templates/forms/macros.html index 12d8e435..cb79a11b 100644 --- a/flowapp/templates/forms/macros.html +++ b/flowapp/templates/forms/macros.html @@ -1,4 +1,4 @@ -{# Renders field for bootstrap 3 standards. +{# Renders field for bootstrap 5 standards. Params: field - WTForm field diff --git a/flowapp/templates/pages/api_key.html b/flowapp/templates/pages/api_key.html index 0b3ee32b..2532d914 100644 --- a/flowapp/templates/pages/api_key.html +++ b/flowapp/templates/pages/api_key.html @@ -6,6 +6,8 @@

Your machines and ApiKeys

Machine address ApiKey + Expires + Read only Action {% for row in keys %} @@ -17,10 +19,26 @@

Your machines and ApiKeys

{{ row.key }} - - - - + {{ row.expires }} + + + {% if row.readonly %} + + + {% endif %} + + + + + + {% if row.comment %} + + {% endif %} + {% endfor %} diff --git a/flowapp/views/api_keys.py b/flowapp/views/api_keys.py index a40c1ec5..2e52691f 100644 --- a/flowapp/views/api_keys.py +++ b/flowapp/views/api_keys.py @@ -56,8 +56,15 @@ def add(): form = ApiKeyForm(request.form, key=generated) if request.method == "POST" and form.validate(): + print("Form validated") + # import ipdb; ipdb.set_trace() model = ApiKey( - machine=form.machine.data, key=form.key.data, user_id=session["user_id"] + machine=form.machine.data, + key=form.key.data, + expires=form.expires.data, + readonly=form.readonly.data, + comment=form.comment.data, + user_id=session["user_id"] ) db.session.add(model) From fb25b20142f463775fbcd5028b4a321bbadeec7c Mon Sep 17 00:00:00 2001 From: Jiri Vrany Date: Thu, 4 Apr 2024 09:02:54 +0200 Subject: [PATCH 12/26] gui updates for ApiKey and MachineApiKey --- flowapp/__about__.py | 2 +- flowapp/forms.py | 26 +++++++ flowapp/instance_config.py | 1 + flowapp/templates/forms/machine_api_key.html | 44 ++++++++++++ flowapp/templates/pages/api_key.html | 2 +- flowapp/templates/pages/machine_api_key.html | 55 +++++++++++++++ flowapp/views/admin.py | 74 +++++++++++++++++++- 7 files changed, 200 insertions(+), 4 deletions(-) create mode 100644 flowapp/templates/forms/machine_api_key.html create mode 100644 flowapp/templates/pages/machine_api_key.html diff --git a/flowapp/__about__.py b/flowapp/__about__.py index be0f7d4e..59b7f434 100755 --- a/flowapp/__about__.py +++ b/flowapp/__about__.py @@ -1 +1 @@ -__version__ = "0.7.3" +__version__ = "0.8.0" diff --git a/flowapp/forms.py b/flowapp/forms.py index 3cf35552..ae83a1e2 100644 --- a/flowapp/forms.py +++ b/flowapp/forms.py @@ -138,6 +138,32 @@ class ApiKeyForm(FlaskForm): key = HiddenField("GeneratedKey") +class MachineApiKeyForm(FlaskForm): + """ + ApiKey for Machines + Each key / machine pair is unique + Only Admin can create new these keys + """ + + machine = StringField( + "Machine address", + validators=[DataRequired(), IPAddress(message="provide valid IP address")], + ) + + comment = TextAreaField( + "Your comment for this key", validators=[Optional(), Length(max=255)] + ) + + expires = MultiFormatDateTimeLocalField( + "Key expiration. Leave blank for non expring key (not-recomended).", + format=FORM_TIME_PATTERN, validators=[Optional()], unlimited=True + ) + + readonly = BooleanField("Read only key", default=False) + + key = HiddenField("GeneratedKey") + + class OrganizationForm(FlaskForm): """ Organization form object diff --git a/flowapp/instance_config.py b/flowapp/instance_config.py index 3c8bafbc..9d5a1bfa 100644 --- a/flowapp/instance_config.py +++ b/flowapp/instance_config.py @@ -78,6 +78,7 @@ class InstanceConfig: ], "admin": [ {"name": "Commands Log", "url": "admin.log"}, + {"name": "Machine keys", "url": "admin.machine_keys"}, { "name": "Users", "url": "admin.users", diff --git a/flowapp/templates/forms/machine_api_key.html b/flowapp/templates/forms/machine_api_key.html new file mode 100644 index 00000000..be6fcaad --- /dev/null +++ b/flowapp/templates/forms/machine_api_key.html @@ -0,0 +1,44 @@ +{% extends 'layouts/default.html' %} +{% from 'forms/macros.html' import render_field, render_checkbox_field %} +{% block title %}Add New Machine with ApiKey{% endblock %} +{% block content %} +

Add new ApiKey for machine.

+

+ In general, the keys should be Read Only and with expiration. + If you need to create a full access Read/Write key, consider using usual user form + with your organization settings. +

+ +
+ +
+
Machine Api Key: {{ generated_key }}
+
+ + + {{ form.hidden_tag() if form.hidden_tag }} +
+
+ {{ render_field(form.machine) }} +
+
+ {{ render_checkbox_field(form.readonly, checked="checked") }} +
+
+ {{ render_field(form.expires) }} +
+
+ +
+
+
+ {{ render_field(form.comment) }} +
+ +
+
+ +
+
+ +{% endblock %} \ No newline at end of file diff --git a/flowapp/templates/pages/api_key.html b/flowapp/templates/pages/api_key.html index 2532d914..cc645887 100644 --- a/flowapp/templates/pages/api_key.html +++ b/flowapp/templates/pages/api_key.html @@ -19,7 +19,7 @@

Your machines and ApiKeys

{{ row.key }} - {{ row.expires }} + {{ row.expires|strftime }} {% if row.readonly %} diff --git a/flowapp/templates/pages/machine_api_key.html b/flowapp/templates/pages/machine_api_key.html new file mode 100644 index 00000000..52eb478a --- /dev/null +++ b/flowapp/templates/pages/machine_api_key.html @@ -0,0 +1,55 @@ +{% extends 'layouts/default.html' %} +{% block title %}ExaFS - ApiKeys{% endblock %} +{% block content %} +

Machines and ApiKeys

+

+ This is the list of all machines and their API keys, created by admin(s). + In general, the keys should be Read Only and with expiration. + If you need to create a full access Read/Write key, use usual user form with your organization settings. +

+ + + + + + + + + + + {% for row in keys %} + + + + + + + + + {% endfor %} +
Machine addressApiKeyCreated byCreated forExpiresRead/Write ?Action
+ {{ row.machine }} + + {{ row.key }} + + {{ row.user.name }} + + {{ row.comment }} + + {{ row.expires|strftime }} + + {% if not row.readonly %} + + + {% endif %} + + + + +
+ + Add new Machine ApiKey + +{% endblock %} \ No newline at end of file diff --git a/flowapp/views/admin.py b/flowapp/views/admin.py index 4b8cc66b..f142a4ae 100644 --- a/flowapp/views/admin.py +++ b/flowapp/views/admin.py @@ -1,12 +1,14 @@ # flowapp/views/admin.py from datetime import datetime, timedelta +import secrets -from flask import Blueprint, render_template, redirect, flash, request, url_for +from flask import Blueprint, render_template, redirect, flash, request, session, url_for from sqlalchemy.exc import IntegrityError -from ..forms import ASPathForm, UserForm, ActionForm, OrganizationForm, CommunityForm +from ..forms import ASPathForm, MachineApiKeyForm, UserForm, ActionForm, OrganizationForm, CommunityForm from ..models import ( ASPath, + MachineApiKey, User, Action, Organization, @@ -42,6 +44,74 @@ def log(page): return render_template("pages/logs.html", logs=logs) +@admin.route("/machine_keys", methods=["GET"]) +@auth_required +@admin_required +def machine_keys(): + """ + Display all machine keys, from all admins + """ + keys = db.session.query(MachineApiKey).all() + + return render_template("pages/machine_api_key.html", keys=keys) + + +@admin.route("/add_machine_key", methods=["GET", "POST"]) +@auth_required +@admin_required +def add_machine_key(): + """ + Add new MachnieApiKey + :return: form or redirect to list of keys + """ + generated = secrets.token_hex(24) + form = MachineApiKeyForm(request.form, key=generated) + + if request.method == "POST" and form.validate(): + print("Form validated") + # import ipdb; ipdb.set_trace() + model = MachineApiKey( + machine=form.machine.data, + key=form.key.data, + expires=form.expires.data, + readonly=form.readonly.data, + comment=form.comment.data, + user_id=session["user_id"] + ) + + db.session.add(model) + db.session.commit() + flash("NewKey saved", "alert-success") + + return redirect(url_for("admin.machine_keys")) + else: + for field, errors in form.errors.items(): + for error in errors: + print( + "Error in the %s field - %s" + % (getattr(form, field).label.text, error) + ) + + return render_template("forms/machine_api_key.html", form=form, generated_key=generated) + + +@admin.route("/delete_machine_key/", methods=["GET"]) +@auth_required +@admin_required +def delete_machine_key(key_id): + """ + Delete api_key and machine + :param key_id: integer + """ + model = db.session.query(MachineApiKey).get(key_id) + # delete from db + db.session.delete(model) + db.session.commit() + flash("Key deleted", "alert-success") + + return redirect(url_for("admin.machine_keys")) + + @admin.route("/user", methods=["GET", "POST"]) @auth_required @admin_required From fa8ce17e50d8e713950f8e4ba171f96854d7e507 Mon Sep 17 00:00:00 2001 From: Jiri Vrany Date: Thu, 4 Apr 2024 09:44:36 +0200 Subject: [PATCH 13/26] readme updated --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index e6c50587..ba8b1f78 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ Last part of the system is Guarda service. This systemctl service is running in * [Local database instalation notes](./docs/DB_LOCAL.md) ## Change Log +- 0.8.0 - API keys update. **Run migration scripts to update your DB**. Keys can now have expiration date and readonly flag. Admin can create special keys for certain machinnes. - 0.7.3 - New possibility of external auth proxy. - 0.7.2 - Dashboard and Main menu are now customizable in config. App is ready to be packaged using setup.py. - 0.7.0 - ExaAPI now have two options - HTTP or RabbitMQ. ExaAPI process has been renamed, update of ExaBGP process value is needed for this version. From 9f32ac0ae35905da4c407e182063b203116ac010 Mon Sep 17 00:00:00 2001 From: Jiri Vrany Date: Thu, 4 Apr 2024 18:45:18 +0200 Subject: [PATCH 14/26] update template filter for strftime --- flowapp/__init__.py | 4 +++- flowapp/views/api_keys.py | 2 -- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/flowapp/__init__.py b/flowapp/__init__.py index a43d6a6b..f92aeeae 100644 --- a/flowapp/__init__.py +++ b/flowapp/__init__.py @@ -174,8 +174,10 @@ def inject_dashboard(): @app.template_filter("strftime") def format_datetime(value): + if value is None: + return app.config.get("MISSING_DATETIME_MESSAGE", "Never") + format = "y/MM/dd HH:mm" - return babel.dates.format_datetime(value, format) def _register_user_to_session(uuid: str): diff --git a/flowapp/views/api_keys.py b/flowapp/views/api_keys.py index 2e52691f..45cc276b 100644 --- a/flowapp/views/api_keys.py +++ b/flowapp/views/api_keys.py @@ -56,8 +56,6 @@ def add(): form = ApiKeyForm(request.form, key=generated) if request.method == "POST" and form.validate(): - print("Form validated") - # import ipdb; ipdb.set_trace() model = ApiKey( machine=form.machine.data, key=form.key.data, From 52cae979fe76b11d10f75c4932b7514037dd3071 Mon Sep 17 00:00:00 2001 From: Jiri Vrany Date: Mon, 15 Jul 2024 12:59:10 +0200 Subject: [PATCH 15/26] quickfix: org adres ranges formatting in template --- flowapp/templates/pages/orgs.html | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/flowapp/templates/pages/orgs.html b/flowapp/templates/pages/orgs.html index 3184160a..9d2b2cb2 100644 --- a/flowapp/templates/pages/orgs.html +++ b/flowapp/templates/pages/orgs.html @@ -4,7 +4,7 @@ - + {% for org in orgs %} @@ -12,7 +12,11 @@
NameAdress RangeAdress Ranges action
{{ org.name }} {% set rows = org.arange.split() %} - {{ rows|join("
") }} +
    + {% for row in rows %} +
  • {{ row }}
  • + {% endfor %} +
From 54d534385b0703a0844a478e1427fb692b8dc2d2 Mon Sep 17 00:00:00 2001 From: Jiri Vrany Date: Fri, 20 Sep 2024 10:35:06 +0200 Subject: [PATCH 16/26] add debug print for rule ids in session --- flowapp/views/rules.py | 52 +++++++++++------------------------------- 1 file changed, 13 insertions(+), 39 deletions(-) diff --git a/flowapp/views/rules.py b/flowapp/views/rules.py index b2e29565..d5840eb3 100644 --- a/flowapp/views/rules.py +++ b/flowapp/views/rules.py @@ -73,9 +73,7 @@ def reactivate_rule(rule_type, rule_id): form.net_ranges = get_user_nets(session["user_id"]) if rule_type > 2: - form.action.choices = [ - (g.id, g.name) for g in db.session.query(Action).order_by("name") - ] + form.action.choices = [(g.id, g.name) for g in db.session.query(Action).order_by("name")] form.action.data = model.action_id if rule_type == 1: @@ -188,6 +186,7 @@ def delete_rule(rule_type, rule_id): flash("Rule deleted", "alert-success") else: + print("in session": [str(x) for x in session[constants.RULES_KEY]]) flash("You can not delete this rule", "alert-warning") return redirect( @@ -256,9 +255,7 @@ def group_delete(): "{} / {}".format(session["user_email"], session["user_orgs"]), ) - db.session.query(model_name).filter(model_name.id.in_(to_delete)).delete( - synchronize_session=False - ) + db.session.query(model_name).filter(model_name.id.in_(to_delete)).delete(synchronize_session=False) db.session.commit() flash("Rules {} deleted".format(to_delete), "alert-success") @@ -309,9 +306,7 @@ def group_update(): form = form_name(request.form) form.net_ranges = get_user_nets(session["user_id"]) if rule_type_int > 2: - form.action.choices = [ - (g.id, g.name) for g in db.session.query(Action).order_by("name") - ] + form.action.choices = [(g.id, g.name) for g in db.session.query(Action).order_by("name")] if rule_type_int == 1: form.community.choices = get_user_communities(session["user_role_ids"]) @@ -429,9 +424,7 @@ def ipv4_rule(): if model: model.expires = round_to_ten_minutes(form.expires.data) - flash_message = ( - "Existing IPv4 Rule found. Expiration time was updated to new value." - ) + flash_message = "Existing IPv4 Rule found. Expiration time was updated to new value." else: model = Flowspec4( source=form.source.data, @@ -473,17 +466,12 @@ def ipv4_rule(): else: for field, errors in form.errors.items(): for error in errors: - print( - "Error in the %s field - %s" - % (getattr(form, field).label.text, error) - ) + print("Error in the %s field - %s" % (getattr(form, field).label.text, error)) default_expires = datetime.now() + timedelta(days=7) form.expires.data = default_expires - return render_template( - "forms/ipv4_rule.html", form=form, action_url=url_for("rules.ipv4_rule") - ) + return render_template("forms/ipv4_rule.html", form=form, action_url=url_for("rules.ipv4_rule")) @rules.route("/add_ipv6_rule", methods=["GET", "POST"]) @@ -507,9 +495,7 @@ def ipv6_rule(): if model: model.expires = round_to_ten_minutes(form.expires.data) - flash_message = ( - "Existing IPv4 Rule found. Expiration time was updated to new value." - ) + flash_message = "Existing IPv4 Rule found. Expiration time was updated to new value." else: model = Flowspec6( source=form.source.data, @@ -550,17 +536,12 @@ def ipv6_rule(): else: for field, errors in form.errors.items(): for error in errors: - print( - "Error in the %s field - %s" - % (getattr(form, field).label.text, error) - ) + print("Error in the %s field - %s" % (getattr(form, field).label.text, error)) default_expires = datetime.now() + timedelta(days=7) form.expires.data = default_expires - return render_template( - "forms/ipv6_rule.html", form=form, action_url=url_for("rules.ipv6_rule") - ) + return render_template("forms/ipv6_rule.html", form=form, action_url=url_for("rules.ipv6_rule")) @rules.route("/add_rtbh_rule", methods=["GET", "POST"]) @@ -586,9 +567,7 @@ def rtbh_rule(): if model: model.expires = round_to_ten_minutes(form.expires.data) - flash_message = ( - "Existing RTBH Rule found. Expiration time was updated to new value." - ) + flash_message = "Existing RTBH Rule found. Expiration time was updated to new value." else: model = RTBH( ipv4=form.ipv4.data, @@ -622,17 +601,12 @@ def rtbh_rule(): else: for field, errors in form.errors.items(): for error in errors: - print( - "Error in the %s field - %s" - % (getattr(form, field).label.text, error) - ) + print("Error in the %s field - %s" % (getattr(form, field).label.text, error)) default_expires = datetime.now() + timedelta(days=7) form.expires.data = default_expires - return render_template( - "forms/rtbh_rule.html", form=form, action_url=url_for("rules.rtbh_rule") - ) + return render_template("forms/rtbh_rule.html", form=form, action_url=url_for("rules.rtbh_rule")) @rules.route("/export") From 3868408ef1778328283cc4807abb36c06fc4362a Mon Sep 17 00:00:00 2001 From: Jiri Vrany Date: Fri, 20 Sep 2024 12:16:00 +0200 Subject: [PATCH 17/26] session ready to test on server --- flowapp/__init__.py | 31 ++++++++++++------------------- flowapp/views/rules.py | 1 - requirements.txt | 4 ++-- run.example.py | 37 +++++++++++++++++-------------------- 4 files changed, 31 insertions(+), 42 deletions(-) diff --git a/flowapp/__init__.py b/flowapp/__init__.py index f92aeeae..61b196f9 100644 --- a/flowapp/__init__.py +++ b/flowapp/__init__.py @@ -6,6 +6,7 @@ from flask_sqlalchemy import SQLAlchemy from flask_wtf.csrf import CSRFProtect from flask_migrate import Migrate +from flask_session import Session from .__about__ import __version__ from .instance_config import InstanceConfig @@ -14,16 +15,12 @@ db = SQLAlchemy() migrate = Migrate() csrf = CSRFProtect() +ext = SSO() +sess = Session() -def create_app(): +def create_app(config_object=None): app = Flask(__name__) - # Map SSO attributes from ADFS to session keys under session['user'] - #: Default attribute map - SSO_ATTRIBUTE_MAP = { - "eppn": (True, "eppn"), - "cn": (False, "cn"), - } # db.init_app(app) migrate.init_app(app, db) @@ -31,13 +28,13 @@ def create_app(): # Load the default configuration for dashboard and main menu app.config.from_object(InstanceConfig) + if config_object: + app.config.from_object(config_object) app.config.setdefault("VERSION", __version__) - app.config.setdefault("SSO_ATTRIBUTE_MAP", SSO_ATTRIBUTE_MAP) - app.config.setdefault("SSO_LOGIN_URL", "/login") - # This attaches the *flask_sso* login handler to the SSO_LOGIN_URL, - ext = SSO(app=app) + # Init SSO + ext.init_app(app) from flowapp import models, constants, validators from .views.admin import admin @@ -85,7 +82,7 @@ def logout(): @app.route("/ext-login") def ext_login(): - header_name = app.config.get("AUTH_HEADER_NAME", 'X-Authenticated-User') + header_name = app.config.get("AUTH_HEADER_NAME", "X-Authenticated-User") if header_name not in request.headers: return render_template("errors/401.html") @@ -148,9 +145,7 @@ def internal_error(exception): def utility_processor(): def editable_rule(rule): if rule: - validators.editable_range( - rule, models.get_user_nets(session["user_id"]) - ) + validators.editable_range(rule, models.get_user_nets(session["user_id"])) return True return False @@ -176,7 +171,7 @@ def inject_dashboard(): def format_datetime(value): if value is None: return app.config.get("MISSING_DATETIME_MESSAGE", "Never") - + format = "y/MM/dd HH:mm" return babel.dates.format_datetime(value, format) @@ -187,9 +182,7 @@ def _register_user_to_session(uuid: str): session["user_name"] = user.name session["user_id"] = user.id session["user_roles"] = [role.name for role in user.role.all()] - session["user_orgs"] = ", ".join( - org.name for org in user.organization.all() - ) + session["user_orgs"] = ", ".join(org.name for org in user.organization.all()) session["user_role_ids"] = [role.id for role in user.role.all()] session["user_org_ids"] = [org.id for org in user.organization.all()] roles = [i > 1 for i in session["user_role_ids"]] diff --git a/flowapp/views/rules.py b/flowapp/views/rules.py index d5840eb3..714f6221 100644 --- a/flowapp/views/rules.py +++ b/flowapp/views/rules.py @@ -186,7 +186,6 @@ def delete_rule(rule_type, rule_id): flash("Rule deleted", "alert-success") else: - print("in session": [str(x) for x in session[constants.RULES_KEY]]) flash("You can not delete this rule", "alert-warning") return redirect( diff --git a/requirements.txt b/requirements.txt index c929dbd7..48c625d7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,14 +1,14 @@ -Flask>=2.0.2 +Flask<3 Flask-SQLAlchemy>=2.2 Flask-SSO>=0.4.0 Flask-WTF>=1.0.0 Flask-Migrate>=3.0.0 Flask-Script>=2.0.0 +Flask-Session PyJWT>=2.4.0 PyMySQL>=1.0.0 pytest>=7.0.0 requests>=2.20.0 babel>=2.7.0 -mysqlclient>=2.0.0 email_validator>=1.1 pika>=1.3.0 diff --git a/run.example.py b/run.example.py index 2911b5bd..21f5667e 100644 --- a/run.example.py +++ b/run.example.py @@ -2,40 +2,37 @@ This is an example of how to run the application. First copy the file as run.py (or whatever you want) Then edit the file to match your needs. + In general you should not need to edit this example file. Only if you want to configure the application main menu and -dashboard. Or in case that you want to add extensions etc. +dashboard. + +Or in case that you want to add extensions etc. """ from os import environ -from flowapp import create_app, db +from flowapp import create_app, db, sess import config -# Call app factory -app = create_app() - # Configurations -env = environ.get('EXAFS_ENV', 'Production') +env = environ.get("EXAFS_ENV", "Production") -if env == 'devel': - app.config.from_object(config.DevelopmentConfig) - app.config.update( - DEVEL=True - ) +# Call app factory +if env == "devel": + app = create_app(config.DevelopmentConfig) else: - app.config.from_object(config.ProductionConfig) - app.config.update( - SESSION_COOKIE_SECURE=True, - SESSION_COOKIE_HTTPONLY=True, - SESSION_COOKIE_SAMESITE='Lax', - DEVEL=False - ) + app = create_app(config.ProductionConfig) # init database object db.init_app(app) +# session on server +app.config.update(SESSION_TYPE="sqlalchemy") +app.config.update(SESSION_SQLALCHEMY=db) +sess.init_app(app) + # run app -if __name__ == '__main__': - app.run(host='::', port=8080, debug=True) +if __name__ == "__main__": + app.run(host="::", port=8080, debug=True) From a354eb2fe115b2ca0eae286499344009c483be43 Mon Sep 17 00:00:00 2001 From: Jiri Vrany Date: Fri, 20 Sep 2024 13:04:38 +0200 Subject: [PATCH 18/26] debug session init --- flowapp/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/flowapp/__init__.py b/flowapp/__init__.py index 61b196f9..7384f1c5 100644 --- a/flowapp/__init__.py +++ b/flowapp/__init__.py @@ -176,6 +176,7 @@ def format_datetime(value): return babel.dates.format_datetime(value, format) def _register_user_to_session(uuid: str): + print(f"registering user {uuid} to session") user = db.session.query(models.User).filter_by(uuid=uuid).first() session["user_uuid"] = user.uuid session["user_email"] = user.uuid @@ -188,4 +189,6 @@ def _register_user_to_session(uuid: str): roles = [i > 1 for i in session["user_role_ids"]] session["can_edit"] = True if all(roles) and roles else [] + print("session", session) + return app From 7ec5a15d2f457ad11999daee6aac636efc143ca7 Mon Sep 17 00:00:00 2001 From: Jiri Vrany Date: Fri, 20 Sep 2024 13:16:20 +0200 Subject: [PATCH 19/26] update run example.py --- run.example.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/run.example.py b/run.example.py index 21f5667e..b7372b12 100644 --- a/run.example.py +++ b/run.example.py @@ -35,4 +35,4 @@ # run app if __name__ == "__main__": - app.run(host="::", port=8080, debug=True) + app.run(host="127.0.0.1", port=8000, debug=True) From 3002dfad1096cff5c42326748c67b3951f7fb9fb Mon Sep 17 00:00:00 2001 From: Jiri Vrany Date: Fri, 20 Sep 2024 13:25:54 +0200 Subject: [PATCH 20/26] lets try filesystem cachelib --- flowapp/__init__.py | 7 +++++++ run.example.py | 6 +----- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/flowapp/__init__.py b/flowapp/__init__.py index 7384f1c5..ff289787 100644 --- a/flowapp/__init__.py +++ b/flowapp/__init__.py @@ -7,6 +7,7 @@ from flask_wtf.csrf import CSRFProtect from flask_migrate import Migrate from flask_session import Session +from cachelib.file import FileSystemCache from .__about__ import __version__ from .instance_config import InstanceConfig @@ -36,6 +37,12 @@ def create_app(config_object=None): # Init SSO ext.init_app(app) + # Init session + app.config.update(SESSION_TYPE="cachelib") + app.config.update(SESSION_SERIALIZATION_FORMAT="json") + app.config.update(SESSION_CACHELIB=FileSystemCache(threshold=500, cache_dir="/sessions")) + sess.init_app(app) + from flowapp import models, constants, validators from .views.admin import admin from .views.rules import rules diff --git a/run.example.py b/run.example.py index b7372b12..9e268c38 100644 --- a/run.example.py +++ b/run.example.py @@ -12,7 +12,7 @@ from os import environ -from flowapp import create_app, db, sess +from flowapp import create_app, db import config @@ -28,10 +28,6 @@ # init database object db.init_app(app) -# session on server -app.config.update(SESSION_TYPE="sqlalchemy") -app.config.update(SESSION_SQLALCHEMY=db) -sess.init_app(app) # run app if __name__ == "__main__": From 372ca44f07ca404e29e32e45405f56ae574aa1c9 Mon Sep 17 00:00:00 2001 From: Jiri Vrany Date: Fri, 20 Sep 2024 13:38:59 +0200 Subject: [PATCH 21/26] typo --- flowapp/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flowapp/__init__.py b/flowapp/__init__.py index ff289787..a44fe1c3 100644 --- a/flowapp/__init__.py +++ b/flowapp/__init__.py @@ -183,7 +183,7 @@ def format_datetime(value): return babel.dates.format_datetime(value, format) def _register_user_to_session(uuid: str): - print(f"registering user {uuid} to session") + print(f"Registering user {uuid} to session") user = db.session.query(models.User).filter_by(uuid=uuid).first() session["user_uuid"] = user.uuid session["user_email"] = user.uuid From 78ca1c3a4c28f83943f9b53b78c1e6790cf413fc Mon Sep 17 00:00:00 2001 From: Jiri Vrany Date: Fri, 20 Sep 2024 13:51:42 +0200 Subject: [PATCH 22/26] sso map! --- flowapp/__init__.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/flowapp/__init__.py b/flowapp/__init__.py index a44fe1c3..a44f40f2 100644 --- a/flowapp/__init__.py +++ b/flowapp/__init__.py @@ -23,6 +23,12 @@ def create_app(config_object=None): app = Flask(__name__) + SSO_ATTRIBUTE_MAP = { + "eppn": (True, "eppn"), + "cn": (False, "cn"), + } + app.config.setdefault("SSO_ATTRIBUTE_MAP", SSO_ATTRIBUTE_MAP) + app.config.setdefault("SSO_LOGIN_URL", "/login") # db.init_app(app) migrate.init_app(app, db) csrf.init_app(app) From 59edcb304f84f8a4db112f29a68a02ab31dcb0a8 Mon Sep 17 00:00:00 2001 From: Jiri Vrany Date: Fri, 20 Sep 2024 14:06:08 +0200 Subject: [PATCH 23/26] back to SQLAlchemy session --- flowapp/__init__.py | 13 +++---------- run.example.py | 7 ++++++- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/flowapp/__init__.py b/flowapp/__init__.py index a44f40f2..fb96b098 100644 --- a/flowapp/__init__.py +++ b/flowapp/__init__.py @@ -7,7 +7,6 @@ from flask_wtf.csrf import CSRFProtect from flask_migrate import Migrate from flask_session import Session -from cachelib.file import FileSystemCache from .__about__ import __version__ from .instance_config import InstanceConfig @@ -23,13 +22,15 @@ def create_app(config_object=None): app = Flask(__name__) + # SSO configuration SSO_ATTRIBUTE_MAP = { "eppn": (True, "eppn"), "cn": (False, "cn"), } app.config.setdefault("SSO_ATTRIBUTE_MAP", SSO_ATTRIBUTE_MAP) app.config.setdefault("SSO_LOGIN_URL", "/login") - # db.init_app(app) + + # extension init migrate.init_app(app, db) csrf.init_app(app) @@ -43,12 +44,6 @@ def create_app(config_object=None): # Init SSO ext.init_app(app) - # Init session - app.config.update(SESSION_TYPE="cachelib") - app.config.update(SESSION_SERIALIZATION_FORMAT="json") - app.config.update(SESSION_CACHELIB=FileSystemCache(threshold=500, cache_dir="/sessions")) - sess.init_app(app) - from flowapp import models, constants, validators from .views.admin import admin from .views.rules import rules @@ -202,6 +197,4 @@ def _register_user_to_session(uuid: str): roles = [i > 1 for i in session["user_role_ids"]] session["can_edit"] = True if all(roles) and roles else [] - print("session", session) - return app diff --git a/run.example.py b/run.example.py index 9e268c38..782ff16e 100644 --- a/run.example.py +++ b/run.example.py @@ -12,7 +12,7 @@ from os import environ -from flowapp import create_app, db +from flowapp import create_app, db, sess import config @@ -28,6 +28,11 @@ # init database object db.init_app(app) +# init session +app.config.update(SESSION_TYPE="sqlalchemy") +app.config.update(SESSION_SQLALCHEMY=db) +sess.init_app(app) + # run app if __name__ == "__main__": From 831f7085aec01d2ee36f85a94252a95faeec7f47 Mon Sep 17 00:00:00 2001 From: Jiri Vrany Date: Mon, 23 Sep 2024 09:21:45 +0200 Subject: [PATCH 24/26] version 0.8.1 with server side session --- README.md | 4 +++- flowapp/__about__.py | 2 +- run.example.py | 4 ++++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ba8b1f78..269047c2 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,9 @@ Last part of the system is Guarda service. This systemctl service is running in * [Local database instalation notes](./docs/DB_LOCAL.md) ## Change Log -- 0.8.0 - API keys update. **Run migration scripts to update your DB**. Keys can now have expiration date and readonly flag. Admin can create special keys for certain machinnes. +- 0.8.1 application is using Flask-Session stored in DB using SQL Alchemy driver. This can be configured for other +drivers, however server side session is required for the application proper function. +- 0.8.0 - API keys update. **Run migration scripts to update your DB**. Keys can now have expiration date and readonly flag. Admin can create special keys for certain machines. - 0.7.3 - New possibility of external auth proxy. - 0.7.2 - Dashboard and Main menu are now customizable in config. App is ready to be packaged using setup.py. - 0.7.0 - ExaAPI now have two options - HTTP or RabbitMQ. ExaAPI process has been renamed, update of ExaBGP process value is needed for this version. diff --git a/flowapp/__about__.py b/flowapp/__about__.py index 59b7f434..6ed043c8 100755 --- a/flowapp/__about__.py +++ b/flowapp/__about__.py @@ -1 +1 @@ -__version__ = "0.8.0" +__version__ = "0.8.1" diff --git a/run.example.py b/run.example.py index 782ff16e..e19c804e 100644 --- a/run.example.py +++ b/run.example.py @@ -3,6 +3,10 @@ First copy the file as run.py (or whatever you want) Then edit the file to match your needs. +From version 0.8.1 the application is using Flask-Session +stored in DB using SQL Alchemy driver. This can be configured for other +drivers, however server side session is required for the application. + In general you should not need to edit this example file. Only if you want to configure the application main menu and dashboard. From 1da865d32f24581e50456f701b7f445159855c5a Mon Sep 17 00:00:00 2001 From: Jiri Vrany Date: Mon, 23 Sep 2024 09:45:50 +0200 Subject: [PATCH 25/26] version 0.8.1 with server side session --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 48c625d7..876a5c61 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,3 +12,4 @@ requests>=2.20.0 babel>=2.7.0 email_validator>=1.1 pika>=1.3.0 +mysqlclient>=2.0.0 From a837a7a28705ee2ad45ede6440ac34a7241bb55f Mon Sep 17 00:00:00 2001 From: Jakub Man Date: Wed, 30 Oct 2024 12:07:55 +0100 Subject: [PATCH 26/26] fix missing rule_id parameter in rule get API endpoints --- flowapp/views/api_v3.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flowapp/views/api_v3.py b/flowapp/views/api_v3.py index 10c7fcf0..8f7b2cb2 100644 --- a/flowapp/views/api_v3.py +++ b/flowapp/views/api_v3.py @@ -91,7 +91,7 @@ def ipv4_rule_get(current_user, rule_id): :param rule_id: :return: """ - return api_common.ipv4_rule_get(current_user) + return api_common.ipv4_rule_get(current_user, rule_id) @api.route("/rules/ipv6/", methods=["GET"]) @@ -103,7 +103,7 @@ def ipv6_rule_get(current_user, rule_id): :param rule_id: :return: """ - return api_common.ipv6_rule_get(current_user) + return api_common.ipv6_rule_get(current_user, rule_id) @api.route("/rules/rtbh/", methods=["GET"]) @@ -115,7 +115,7 @@ def rtbh_rule_get(current_user, rule_id): :param rule_id: :return: """ - return api_common.rtbh_rule_get(current_user) + return api_common.rtbh_rule_get(current_user, rule_id) @api.route("/rules/ipv4/", methods=["DELETE"])