diff --git a/README.md b/README.md index e6c50587..269047c2 100644 --- a/README.md +++ b/README.md @@ -52,6 +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.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 be0f7d4e..6ed043c8 100755 --- a/flowapp/__about__.py +++ b/flowapp/__about__.py @@ -1 +1 @@ -__version__ = "0.7.3" +__version__ = "0.8.1" diff --git a/flowapp/__init__.py b/flowapp/__init__.py index a43d6a6b..fb96b098 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,30 +15,34 @@ 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 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) # 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 +90,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 +153,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 @@ -174,20 +177,21 @@ def inject_dashboard(): @app.template_filter("strftime") def format_datetime(value): - format = "y/MM/dd HH:mm" + 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): + 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 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/forms.py b/flowapp/forms.py index 16bdc0f7..ae83a1e2 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,43 @@ 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") + + +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") 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/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/templates/forms/api_key.html b/flowapp/templates/forms/api_key.html index 9d8901cb..6a788723 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/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/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..cc645887 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|strftime }} + + + {% if row.readonly %} + + + {% endif %} + + + + + + {% if row.comment %} + + {% endif %} + {% endfor %} 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 }}
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/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 %} +
diff --git a/flowapp/tests/conftest.py b/flowapp/tests/conftest.py index ed3c378d..1d5436db 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,54 @@ 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. """ + mkey = "testkey" + + with app.app_context(): + 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/v3/auth" + headers = {"x-api-key": mkey} + token = client.get(url, headers=headers) + 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/v1/auth/testkey" - token = client.get(url) + 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..5733346b --- /dev/null +++ b/flowapp/tests/test_api_auth.py @@ -0,0 +1,62 @@ +# 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/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/admin.py b/flowapp/views/admin.py index 4b8cc66b..b90daa40 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,17 +44,78 @@ 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 def user(): form = UserForm(request.form) - form.role_ids.choices = [ - (g.id, g.name) for g in db.session.query(Role).order_by("name") - ] - form.org_ids.choices = [ - (g.id, g.name) for g in db.session.query(Organization).order_by("name") - ] + form.role_ids.choices = [(g.id, g.name) for g in db.session.query(Role).order_by("name")] + form.org_ids.choices = [(g.id, g.name) for g in db.session.query(Organization).order_by("name")] if request.method == "POST" and form.validate(): # test if user is unique @@ -87,12 +150,8 @@ def user(): def edit_user(user_id): user = db.session.query(User).get(user_id) form = UserForm(request.form, obj=user) - form.role_ids.choices = [ - (g.id, g.name) for g in db.session.query(Role).order_by("name") - ] - form.org_ids.choices = [ - (g.id, g.name) for g in db.session.query(Organization).order_by("name") - ] + form.role_ids.choices = [(g.id, g.name) for g in db.session.query(Role).order_by("name")] + form.org_ids.choices = [(g.id, g.name) for g in db.session.query(Organization).order_by("name")] if request.method == "POST" and form.validate(): user.update(form) @@ -163,9 +222,7 @@ def organization(): flash("Organization saved") return redirect(url_for("admin.organizations")) else: - flash( - "Organization {} already exists".format(form.name.data), "alert-danger" - ) + flash("Organization {} already exists".format(form.name.data), "alert-danger") action_url = url_for("admin.organization") return render_template( @@ -321,9 +378,7 @@ def action(): return redirect(url_for("admin.actions")) else: flash( - "Action with name {} or command {} already exists".format( - form.name.data, form.command.data - ), + "Action with name {} or command {} already exists".format(form.name.data, form.command.data), "alert-danger", ) @@ -410,9 +465,7 @@ def community(): flash("Community saved", "alert-success") return redirect(url_for("admin.communities")) else: - flash( - f"Community with name {form.name.data} already exists", "alert-danger" - ) + flash(f"Community with name {form.name.data} already exists", "alert-danger") community_url = url_for("admin.community") return render_template( @@ -457,9 +510,7 @@ def delete_community(community_id): try: db.session.commit() except IntegrityError: - message = "Community {} is in use in some rules, can not be deleted!".format( - aname - ) + message = "Community {} is in use in some rules, can not be deleted!".format(aname) alert_type = "alert-danger" flash(message, alert_type) diff --git a/flowapp/views/api_common.py b/flowapp/views/api_common.py index 515e0c90..163449ce 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 "" @@ -455,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 @@ -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_keys.py b/flowapp/views/api_keys.py index a40c1ec5..45cc276b 100644 --- a/flowapp/views/api_keys.py +++ b/flowapp/views/api_keys.py @@ -57,7 +57,12 @@ def add(): if request.method == "POST" and form.validate(): 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) 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 094b2a52..8f7b2cb2 100644 --- a/flowapp/views/api_v3.py +++ b/flowapp/views/api_v3.py @@ -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 @@ -62,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 @@ -75,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) @@ -88,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"]) @@ -100,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"]) @@ -112,11 +115,12 @@ 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"]) @api_common.token_required +@api_common.check_readonly def delete_v4_rule(current_user, rule_id): """ Delete rule with given id and type @@ -127,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 @@ -137,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 diff --git a/flowapp/views/rules.py b/flowapp/views/rules.py index b2e29565..714f6221 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: @@ -256,9 +254,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 +305,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 +423,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 +465,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 +494,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 +535,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 +566,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 +600,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") 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 ### diff --git a/requirements.txt b/requirements.txt index c929dbd7..876a5c61 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,14 +1,15 @@ -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 +mysqlclient>=2.0.0 diff --git a/run.example.py b/run.example.py index 2911b5bd..e19c804e 100644 --- a/run.example.py +++ b/run.example.py @@ -2,40 +2,42 @@ 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. + +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. 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) +# init session +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="127.0.0.1", port=8000, debug=True)