diff --git a/integrationtest-github.runsettings b/integrationtest-github.runsettings new file mode 100644 index 0000000000..c993e354a5 --- /dev/null +++ b/integrationtest-github.runsettings @@ -0,0 +1,9 @@ + + + + + runner + true + + + diff --git a/integrationtest-local.runsettings b/integrationtest-local.runsettings new file mode 100644 index 0000000000..035a039922 --- /dev/null +++ b/integrationtest-local.runsettings @@ -0,0 +1,9 @@ + + + + + http://localhost:8881 + LOCALTEST + + + diff --git a/inventory/group_vars/testservers.yml b/inventory/group_vars/testservers.yml index 35a634ca51..f6c311f447 100644 --- a/inventory/group_vars/testservers.yml +++ b/inventory/group_vars/testservers.yml @@ -2,3 +2,7 @@ install_webhook: no # webhook_install_mode: reinstall webhook_install_mode: upgrade webhook_branch: develop + +# Integration test user configuration +integration_test_user_pw: testpassword +integration_test_user_name: integration_user_jwt_refresh_test diff --git a/roles/api/files/replace_metadata.json b/roles/api/files/replace_metadata.json index f4fc69536c..1a42d6ab0c 100644 --- a/roles/api/files/replace_metadata.json +++ b/roles/api/files/replace_metadata.json @@ -3545,9 +3545,6 @@ "change_time", "unique_name" ], - "computed_fields": [ - "cl_rule_relevant_for_tenant" - ], "filter": {} } }, @@ -3575,9 +3572,6 @@ "change_time", "docu_time" ], - "computed_fields": [ - "cl_rule_relevant_for_tenant" - ], "filter": {}, "allow_aggregations": true } @@ -3637,9 +3631,6 @@ "change_time", "docu_time" ], - "computed_fields": [ - "cl_rule_relevant_for_tenant" - ], "filter": {} }, "comment": "" @@ -3766,9 +3757,6 @@ "change_time", "unique_name" ], - "computed_fields": [ - "cl_rule_relevant_for_tenant" - ], "filter": {}, "allow_aggregations": true } @@ -10582,12 +10570,6 @@ "schema": "public" }, "object_relationships": [ - { - "name": "owner_lifecycle_state", - "using": { - "foreign_key_constraint_on": "owner_lifecycle_state_id" - } - }, { "name": "tenant", "using": { @@ -11292,21 +11274,7 @@ "table": { "name": "owner_lifecycle_state", "schema": "public" - }, - "array_relationships": [ - { - "name": "owners", - "using": { - "foreign_key_constraint_on": { - "column": "owner_lifecycle_state_id", - "table": { - "name": "owner", - "schema": "public" - } - } - } - } - ] + } }, { "table": { @@ -12130,6 +12098,59 @@ } ] }, + { + "table": { + "name": "refresh_tokens", + "schema": "public" + }, + "insert_permissions": [ + { + "role": "middleware-server", + "permission": { + "check": {}, + "columns": [ + "token_hash", + "id", + "user_id", + "created_at", + "expires_at", + "revoked_at" + ] + }, + "comment": "" + } + ], + "select_permissions": [ + { + "role": "middleware-server", + "permission": { + "columns": [ + "token_hash", + "id", + "user_id", + "created_at", + "expires_at", + "revoked_at" + ], + "filter": {} + }, + "comment": "" + } + ], + "update_permissions": [ + { + "role": "middleware-server", + "permission": { + "columns": [ + "revoked_at" + ], + "filter": {}, + "check": {} + }, + "comment": "" + } + ] + }, { "table": { "name": "report", @@ -14917,14 +14938,12 @@ "permission": { "columns": [ "parent_rule_id", - "removed", "rule_create", "rule_id", "rule_last_seen", "xlate_rule", "access_rule", "active", - "is_global", "nat_rule", "rule_disabled", "rule_dst_neg", @@ -14939,7 +14958,6 @@ "dev_id", "last_change_admin", "mgm_id", - "rulebase_id", "rule_from_zone", "rule_num", "rule_to_zone", @@ -14971,14 +14989,12 @@ "permission": { "columns": [ "parent_rule_id", - "removed", "rule_create", "rule_id", "rule_last_seen", "xlate_rule", "access_rule", "active", - "is_global", "nat_rule", "rule_disabled", "rule_dst_neg", @@ -14993,7 +15009,6 @@ "dev_id", "last_change_admin", "mgm_id", - "rulebase_id", "rule_from_zone", "rule_num", "rule_to_zone", @@ -15078,48 +15093,47 @@ "role": "middleware-server", "permission": { "columns": [ - "parent_rule_id", - "removed", - "rule_create", - "rule_id", - "rule_last_seen", - "xlate_rule", "access_rule", - "active", - "is_global", - "nat_rule", - "rule_disabled", - "rule_dst_neg", - "rule_implied", - "rule_src_neg", - "rule_svc_neg", - "rule_installon", - "rule_name", - "rule_ruleid", - "rule_time", "action_id", + "active", "dev_id", "last_change_admin", "mgm_id", - "rulebase_id", - "rule_from_zone", - "rule_num", - "rule_to_zone", - "track_id", - "rule_custom_fields", - "rule_num_numeric", + "nat_rule", + "parent_rule_id", "parent_rule_type", + "removed", "rule_action", "rule_comment", + "rule_create", + "rule_custom_fields", + "rule_disabled", "rule_dst", + "rule_dst_neg", "rule_dst_refs", + "rule_from_zone", "rule_head_text", + "rule_id", + "rule_implied", + "rule_installon", + "rule_last_seen", + "rule_name", + "rule_num", + "rule_num_numeric", + "rule_ruleid", "rule_src", + "rule_src_neg", "rule_src_refs", "rule_svc", + "rule_svc_neg", "rule_svc_refs", + "rule_time", + "rule_to_zone", "rule_track", - "rule_uid" + "rule_uid", + "rulebase_id", + "track_id", + "xlate_rule" ], "computed_fields": [ "rule_relevant_for_tenant" @@ -15133,14 +15147,12 @@ "permission": { "columns": [ "parent_rule_id", - "removed", "rule_create", "rule_id", "rule_last_seen", "xlate_rule", "access_rule", "active", - "is_global", "nat_rule", "rule_disabled", "rule_dst_neg", @@ -15155,7 +15167,6 @@ "dev_id", "last_change_admin", "mgm_id", - "rulebase_id", "rule_from_zone", "rule_num", "rule_to_zone", @@ -15200,47 +15211,39 @@ "permission": { "columns": [ "access_rule", - "action_id", "active", + "nat_rule", + "rule_disabled", + "rule_dst_neg", + "rule_implied", + "rule_src_neg", + "rule_svc_neg", + "rule_installon", + "rule_name", + "rule_ruleid", + "rule_time", + "action_id", "dev_id", - "is_global", "last_change_admin", "mgm_id", - "nat_rule", - "parent_rule_id", + "rule_from_zone", + "rule_num", + "rule_to_zone", + "track_id", + "rule_custom_fields", + "rule_num_numeric", "parent_rule_type", - "removed", "rule_action", "rule_comment", - "rule_create", - "rule_custom_fields", - "rule_disabled", "rule_dst", - "rule_dst_neg", "rule_dst_refs", - "rule_from_zone", "rule_head_text", - "rule_id", - "rule_implied", - "rule_installon", - "rule_last_seen", - "rule_name", - "rule_num", - "rule_num_numeric", - "rule_ruleid", "rule_src", - "rule_src_neg", "rule_src_refs", "rule_svc", - "rule_svc_neg", "rule_svc_refs", - "rule_time", - "rule_to_zone", "rule_track", - "rule_uid", - "rulebase_id", - "track_id", - "xlate_rule" + "rule_uid" ], "computed_fields": [ "rule_relevant_for_tenant" @@ -15267,14 +15270,12 @@ "permission": { "columns": [ "parent_rule_id", - "removed", "rule_create", "rule_id", "rule_last_seen", "xlate_rule", "access_rule", "active", - "is_global", "nat_rule", "rule_disabled", "rule_dst_neg", @@ -15289,7 +15290,6 @@ "dev_id", "last_change_admin", "mgm_id", - "rulebase_id", "rule_from_zone", "rule_num", "rule_to_zone", @@ -15334,14 +15334,12 @@ "permission": { "columns": [ "parent_rule_id", - "removed", "rule_create", "rule_id", "rule_last_seen", "xlate_rule", "access_rule", "active", - "is_global", "nat_rule", "rule_disabled", "rule_dst_neg", @@ -15356,7 +15354,6 @@ "dev_id", "last_change_admin", "mgm_id", - "rulebase_id", "rule_from_zone", "rule_num", "rule_to_zone", @@ -15677,15 +15674,14 @@ "role": "auditor", "permission": { "columns": [ - "active", - "negated", "obj_id", - "removed", "rf_create", "rf_last_seen", "rule_from_id", "rule_id", - "user_id" + "user_id", + "active", + "negated" ], "computed_fields": [ "rule_from_relevant_for_tenant" @@ -15698,16 +15694,15 @@ "role": "fw-admin", "permission": { "columns": [ - "active", - "negated", "obj_id", - "removed", "rf_create", "rf_last_seen", "removed", "rule_from_id", "rule_id", - "user_id" + "user_id", + "active", + "negated" ], "computed_fields": [ "rule_from_relevant_for_tenant" @@ -15752,9 +15747,6 @@ "rule_id", "user_id" ], - "computed_fields": [ - "rule_from_relevant_for_tenant" - ], "filter": {} }, "comment": "" @@ -15764,7 +15756,6 @@ "permission": { "columns": [ "obj_id", - "removed", "rf_create", "rf_last_seen", "rule_from_id", @@ -15799,15 +15790,14 @@ "role": "recertifier", "permission": { "columns": [ - "active", - "negated", "obj_id", - "removed", "rf_create", "rf_last_seen", "rule_from_id", "rule_id", - "user_id" + "user_id", + "active", + "negated" ], "computed_fields": [ "rule_from_relevant_for_tenant" @@ -15836,7 +15826,6 @@ "permission": { "columns": [ "obj_id", - "removed", "rf_create", "rf_last_seen", "rule_from_id", @@ -15872,7 +15861,6 @@ "permission": { "columns": [ "obj_id", - "removed", "rf_create", "rf_last_seen", "rule_from_id", @@ -16706,13 +16694,13 @@ "role": "auditor", "permission": { "columns": [ + "rule_id", + "svc_id", "active", - "negated", - "removed", "rs_create", "rs_last_seen", - "rule_id", - "svc_id" + "removed", + "negated" ], "filter": {} } @@ -17108,15 +17096,14 @@ "role": "auditor", "permission": { "columns": [ - "active", - "negated", "obj_id", - "removed", "rt_create", "rt_last_seen", "rule_id", "rule_to_id", - "user_id" + "user_id", + "active", + "negated" ], "computed_fields": [ "rule_to_relevant_for_tenant" @@ -17131,7 +17118,6 @@ "active", "negated", "obj_id", - "removed", "rt_create", "rt_last_seen", "rule_id", @@ -17180,9 +17166,6 @@ "rule_to_id", "user_id" ], - "computed_fields": [ - "rule_to_relevant_for_tenant" - ], "filter": {} } }, @@ -17193,7 +17176,6 @@ "active", "negated", "obj_id", - "removed", "rt_create", "rt_last_seen", "rule_id", @@ -17226,15 +17208,14 @@ "role": "recertifier", "permission": { "columns": [ - "active", - "negated", "obj_id", - "removed", "rt_create", "rt_last_seen", "rule_id", "rule_to_id", - "user_id" + "user_id", + "active", + "negated" ], "computed_fields": [ "rule_to_relevant_for_tenant" @@ -17265,7 +17246,6 @@ "active", "negated", "obj_id", - "removed", "rt_create", "rt_last_seen", "rule_id", @@ -17298,15 +17278,14 @@ "role": "reporter-viewall", "permission": { "columns": [ - "active", - "negated", "obj_id", - "removed", "rt_create", "rt_last_seen", "rule_id", "rule_to_id", - "user_id" + "user_id", + "active", + "negated" ], "computed_fields": [ "rule_to_relevant_for_tenant" diff --git a/roles/common/files/fwo-api-calls/auth/getRefreshToken.graphql b/roles/common/files/fwo-api-calls/auth/getRefreshToken.graphql new file mode 100644 index 0000000000..34517dc270 --- /dev/null +++ b/roles/common/files/fwo-api-calls/auth/getRefreshToken.graphql @@ -0,0 +1,8 @@ +query getRefreshToken($tokenHash: String!, $currentTime: timestamptz!) { + refresh_tokens(where: {token_hash: {_eq: $tokenHash}, expires_at: {_gt: $currentTime}, revoked_at: {_is_null: true}}) { + user_id + expires_at + created_at + revoked_at + } +} diff --git a/roles/common/files/fwo-api-calls/auth/revokeRefreshToken.graphql b/roles/common/files/fwo-api-calls/auth/revokeRefreshToken.graphql new file mode 100644 index 0000000000..62a31a278e --- /dev/null +++ b/roles/common/files/fwo-api-calls/auth/revokeRefreshToken.graphql @@ -0,0 +1,10 @@ +# GraphQL mutation to revoke a refresh token by setting its revoked_at timestamp + +mutation revokeRefreshToken($tokenHash: String!, $revokedAt: timestamptz!) { + update_refresh_tokens( + where: {token_hash: {_eq: $tokenHash}}, + _set: {revoked_at: $revokedAt} + ) { + affected_rows + } +} \ No newline at end of file diff --git a/roles/common/files/fwo-api-calls/auth/storeRefreshToken.graphql b/roles/common/files/fwo-api-calls/auth/storeRefreshToken.graphql new file mode 100644 index 0000000000..f2df7c3598 --- /dev/null +++ b/roles/common/files/fwo-api-calls/auth/storeRefreshToken.graphql @@ -0,0 +1,7 @@ +# GraphQL mutation to store a refresh token in the database + +mutation storeRefreshToken($userId: Int!, $tokenHash: String!, $expiresAt: timestamptz!, $createdAt: timestamptz!) { + insert_refresh_tokens_one(object: {user_id: $userId, token_hash: $tokenHash, expires_at: $expiresAt, created_at: $createdAt}) { + id + } +} \ No newline at end of file diff --git a/roles/database/files/sql/creation/fworch-create-tables.sql b/roles/database/files/sql/creation/fworch-create-tables.sql index 276cdd09d1..49b572a1dd 100755 --- a/roles/database/files/sql/creation/fworch-create-tables.sql +++ b/roles/database/files/sql/creation/fworch-create-tables.sql @@ -6,7 +6,7 @@ Contact https://cactus.de/fworch Database PostgreSQL 9-13 */ -/* Create Sequence +/* Create Sequence the abs_hange_id is needed as it is incremented across 4 different tables @@ -59,7 +59,7 @@ Create table "management" -- contains an entry for each firewall management syst "mgm_name" Varchar NOT NULL, "mgm_comment" Text, "cloud_tenant_id" VARCHAR, - "cloud_subscription_id" VARCHAR, + "cloud_subscription_id" VARCHAR, "mgm_create" Timestamp NOT NULL Default now(), "mgm_update" Timestamp NOT NULL Default now(), "import_credential_id" Integer NOT NULL, @@ -213,7 +213,7 @@ Create table "rule_metadata" "last_change_admin" Integer, "rule_decert_date" Timestamp, "rule_recertification_comment" Varchar, - primary key ("rule_metadata_id") + primary key ("rule_metadata_id") ); -- adding direct link tables rule_[svc|nwobj|user]_resolved to make report object export easier @@ -506,7 +506,7 @@ Create table "tenant" "tenant_comment" Text, "tenant_report" Boolean Default true, "tenant_can_view_all_devices" Boolean NOT NULL Default false, - "tenant_is_superadmin" Boolean NOT NULL default false, + "tenant_is_superadmin" Boolean NOT NULL default false, "tenant_create" Timestamp NOT NULL Default now(), primary key ("tenant_id") ); @@ -1039,7 +1039,7 @@ Create table "report_schedule" "report_template_id" Integer, --FK "report_schedule_owner" Integer NOT NULL, --FK "report_schedule_start_time" Timestamp NOT NULL, -- if day is bigger than 28, simply use the 1st of the next month, 00:00 am - "report_schedule_repeat" Integer Not NULL Default 0, -- 0 do not repeat, 1 daily, 2 weekly, 3 monthly, 4 yearly + "report_schedule_repeat" Integer Not NULL Default 0, -- 0 do not repeat, 1 daily, 2 weekly, 3 monthly, 4 yearly "report_schedule_every" Integer Not NULL Default 1, -- x - every x days/weeks/months/years "report_schedule_active" Boolean Default TRUE, "report_schedule_repetitions" Integer, @@ -1151,7 +1151,7 @@ create table owner_network port int, ip_proto_id int, nw_type int, - import_source Varchar default 'manual', + import_source Varchar default 'manual', is_deleted boolean default false, custom_type int ); @@ -1183,7 +1183,7 @@ create table recertification owner_recert_id bigint ); -Create Table IF NOT EXISTS "rule_enforced_on_gateway" +Create Table IF NOT EXISTS "rule_enforced_on_gateway" ( "rule_id" Integer NOT NULL, "dev_id" Integer, -- NULL if rule is available for all gateways of its management @@ -1265,7 +1265,7 @@ CREATE TYPE rule_field_enum AS ENUM ('source', 'destination', 'service', 'rule', CREATE TYPE action_enum AS ENUM ('create', 'delete', 'modify', 'unchanged', 'addAfterCreation'); -- create tables -create table request.reqtask +create table request.reqtask ( id BIGSERIAL PRIMARY KEY, title VARCHAR, @@ -1294,7 +1294,7 @@ create table request.reqtask mgm_id int ); -create table request.reqelement +create table request.reqelement ( id BIGSERIAL PRIMARY KEY, request_action action_enum NOT NULL default 'create', @@ -1315,7 +1315,7 @@ create table request.reqelement name varchar ); -create table request.approval +create table request.approval ( id BIGSERIAL PRIMARY KEY, task_id bigint, @@ -1332,7 +1332,7 @@ create table request.approval state_id int NOT NULL ); -create table request.ticket +create table request.ticket ( id BIGSERIAL PRIMARY KEY, title VARCHAR NOT NULL, @@ -1353,7 +1353,7 @@ create table request.ticket ticket_priority int ); -create table request.comment +create table request.comment ( id BIGSERIAL PRIMARY KEY, ref_id bigint, @@ -1690,3 +1690,12 @@ create table modelling.change_history change_time Timestamp default now(), change_source Varchar default 'manual' ); + +CREATE TABLE refresh_tokens ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES uiuser(uiuser_id) ON DELETE CASCADE, + token_hash VARCHAR(88) UNIQUE NOT NULL, + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + revoked_at TIMESTAMP WITH TIME ZONE NULL +); \ No newline at end of file diff --git a/roles/database/files/sql/idempotent/fworch-texts.sql b/roles/database/files/sql/idempotent/fworch-texts.sql index 0426e3cfef..60dc5bd1ac 100644 --- a/roles/database/files/sql/idempotent/fworch-texts.sql +++ b/roles/database/files/sql/idempotent/fworch-texts.sql @@ -2961,6 +2961,18 @@ INSERT INTO txt VALUES ('import_app_server', 'German', 'App Server importie INSERT INTO txt VALUES ('import_app_server', 'English', 'Import app servers'); INSERT INTO txt VALUES ('import_matrix', 'German', 'Matrix-Import'); INSERT INTO txt VALUES ('import_matrix', 'English', 'Matrix Import'); +INSERT INTO txt VALUES ('token_refresh', 'German', 'Token erneuern'); +INSERT INTO txt VALUES ('token_refresh', 'English', 'Refresh Token'); +INSERT INTO txt VALUES ('token_revoke', 'German', 'Token zurückziehen'); +INSERT INTO txt VALUES ('token_revoke', 'English', 'Revoke Token'); +INSERT INTO txt VALUES ('response', 'German', 'Antwort'); +INSERT INTO txt VALUES ('response', 'English', 'Response'); +INSERT INTO txt VALUES ('missing_refresh_token','German', 'Fehlender Refresh Token'); +INSERT INTO txt VALUES ('missing_refresh_token','English', 'Missing Refresh Token'); +INSERT INTO txt VALUES ('invalid_refresh_token','German', 'Ungültiger oder abgelaufener Refresh Token'); +INSERT INTO txt VALUES ('invalid_refresh_token','English', 'Invalid or Expired Refresh Token'); +INSERT INTO txt VALUES ('token_revoke_success', 'German', 'Token erfolgreich zurückgezogen'); +INSERT INTO txt VALUES ('token_revoke_success', 'English', 'Successful Token Revocation'); -- user messages INSERT INTO txt VALUES ('U0001', 'German', 'Eingabetext wurde um nicht erlaubte Zeichen gekürzt'); @@ -3263,6 +3275,8 @@ INSERT INTO txt VALUES ('U9034', 'German', 'Es ist noch ein Firewall-Änder INSERT INTO txt VALUES ('U9034', 'English', 'A Firewall Change request is running!'); INSERT INTO txt VALUES ('U9035', 'German', 'Sind sie sicher, dass sie folgende Schnittstelle stillegen wollen: '); INSERT INTO txt VALUES ('U9035', 'English', 'Are you sure you want to decommission following interface: '); +INSERT INTO txt VALUES ('U9036', 'German', 'Die Gültigkeitsdauer des Zugriffstokens darf die Gültigkeitsdauer des Aktualisierungstokens nicht überschreiten.'); +INSERT INTO txt VALUES ('U9036', 'English', 'Access token lifetime cannot exceed refresh token lifetime.'); -- error messages INSERT INTO txt VALUES ('E0001', 'German', 'Nicht klassifizierter Fehler: '); @@ -4164,7 +4178,7 @@ INSERT INTO txt VALUES ('H3001', 'English', 'Here the archived reports can be fo They may be created on the one hand by exporting manually created reports with setting the flag "Archive" in Export Report. On the other hand here also the reports created by the Scheduling or in the recertification process can be found. It is possible to download or delete (except recertifications) these archived reports. - In the left sidebar the report display can be restricted to the particular report types. + In the left sidebar the report display can be restricted to the particular report types. '); INSERT INTO txt VALUES ('H4011', 'German', 'Im ersten Schritt muss ein Report mit den demnächst zu rezertifizierenden Regeln geladen werden. @@ -5852,11 +5866,11 @@ INSERT INTO txt VALUES ('H5660', 'English', 'Receiver of decommission emails: Se INSERT INTO txt VALUES ('H5661', 'German', 'Titel der Stilllegungsbenachrichtigung: Betreff der Email-Benachrichtigung an die betroffenen Eigentümer. Platzhalter @@INTERFACE_NAME@@ werden mit dem Namen der zu löschenden Schnittstelle ersetzt.'); INSERT INTO txt VALUES ('H5661', 'English', 'Subject of decommission emails: Subject of the email to the addressed owners. Placeholders @@INTERFACE_NAME@@ will be replaced by the name of the interface to be decommissioned.'); INSERT INTO txt VALUES ('H5662', 'German', 'Text der Stilllegungsbenachrichtigung: Text der Email-Benachrichtigung an die Nutzer der Schnittstelle, gefolgt von der Liste der betroffenen Verbindungen. Es können folgende Platzhalter genutzt werden: - @@INTERFACE_NAME@@ wird durch den Namen der stillzulegenden Schnittstelle ersetzt, @@NEW_INTERFACE_NAME@@ mit dem Namen der vorgeschlagenen Ersatzschnittstelle, @@NEW_INTERFACE_LINK@@ mit einem Link auf diese, + @@INTERFACE_NAME@@ wird durch den Namen der stillzulegenden Schnittstelle ersetzt, @@NEW_INTERFACE_NAME@@ mit dem Namen der vorgeschlagenen Ersatzschnittstelle, @@NEW_INTERFACE_LINK@@ mit einem Link auf diese, @@REASON@@ mit dem Begründungstext, der im Stillegungsformular eingegeben wurde, @@USER_NAME@@ mit dem Nutzer, der die Stillegung veranlasst hat. '); INSERT INTO txt VALUES ('H5662', 'English', 'Body of decommission emails: Text of the email notification to the addressed owners, followed by a list of the affected connections. Some placeholders can be used: - @@INTERFACE_NAME@@ will be replaced by the name of the interface to be decommissioned, @@NEW_INTERFACE_NAME@@ by the name of the proposed new interface, @@NEW_INTERFACE_LINK@@ by a link to this interface, + @@INTERFACE_NAME@@ will be replaced by the name of the interface to be decommissioned, @@NEW_INTERFACE_NAME@@ by the name of the proposed new interface, @@NEW_INTERFACE_LINK@@ by a link to this interface, @@REASON@@ by the reason text filled in the decommission form, @@USER_NAME@@ by the user initiating the decommissioning. '); INSERT INTO txt VALUES ('H5663', 'German', 'Alle Regeln modelliert erwarten: Alle dem Eigentümer zugeordneten Regeln müssen modelliert sein.'); diff --git a/roles/database/files/upgrade/9.0.sql b/roles/database/files/upgrade/9.0.sql index 4a65dff012..bfbff09030 100644 --- a/roles/database/files/upgrade/9.0.sql +++ b/roles/database/files/upgrade/9.0.sql @@ -50,10 +50,10 @@ $$ LANGUAGE plpgsql VOLATILE; Alter table "ldap_connection" ADD COLUMN IF NOT EXISTS "ldap_writepath_for_groups" Varchar; CREATE OR REPLACE FUNCTION insertLocalLdapWithEncryptedPasswords( - serverName TEXT, + serverName TEXT, port INTEGER, userSearchPath TEXT, - roleSearchPath TEXT, + roleSearchPath TEXT, groupSearchPath TEXT, groupWritePath TEXT, tenantLevel INTEGER, @@ -114,7 +114,7 @@ insert into stm_track (track_id,track_name) VALUES (23,'detailed log') ON CONFLI insert into stm_track (track_id,track_name) VALUES (24,'extended log') ON CONFLICT DO NOTHING; -- check point R8x -- 8.8.8 -DO $$ +DO $$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'fwo_ro') THEN CREATE ROLE fwo_ro WITH LOGIN NOSUPERUSER INHERIT NOCREATEDB NOCREATEROLE; @@ -373,19 +373,19 @@ BEGIN SELECT INTO i_mgm_id mgm_id FROM import_control WHERE control_id=NEW.import_id; -- before importing, delete all old interfaces and routes belonging to the current management: - -- now re-insert the currently found interfaces: + -- now re-insert the currently found interfaces: SELECT INTO i_count COUNT(*) FROM jsonb_populate_recordset(NULL::gw_interface, NEW.config -> 'interfaces'); IF i_count>0 THEN - DELETE FROM gw_interface WHERE routing_device IN + DELETE FROM gw_interface WHERE routing_device IN (SELECT dev_id FROM device LEFT JOIN management ON (device.mgm_id=management.mgm_id) WHERE management.mgm_id=i_mgm_id); INSERT INTO gw_interface SELECT * FROM jsonb_populate_recordset(NULL::gw_interface, NEW.config -> 'interfaces'); END IF; SELECT INTO i_count COUNT(*) FROM jsonb_populate_recordset(NULL::gw_route, NEW.config -> 'routing'); IF i_count>0 THEN - DELETE FROM gw_route WHERE routing_device IN + DELETE FROM gw_route WHERE routing_device IN (SELECT dev_id FROM device LEFT JOIN management ON (device.mgm_id=management.mgm_id) WHERE management.mgm_id=i_mgm_id); - -- now re-insert the currently found routes: + -- now re-insert the currently found routes: INSERT INTO gw_route SELECT * FROM jsonb_populate_recordset(NULL::gw_route, NEW.config -> 'routing'); END IF; @@ -423,7 +423,7 @@ BEGIN IF NEW.start_import_flag THEN -- finally start the stored procedure import - PERFORM import_all_main(NEW.import_id, NEW.debug_mode); + PERFORM import_all_main(NEW.import_id, NEW.debug_mode); END IF; RETURN NEW; END; @@ -452,7 +452,7 @@ ALTER TABLE device ADD COLUMN IF NOT EXISTS "dev_uid" Varchar NOT NULL DEFAULT ' Alter table stm_action add column if not exists allowed BOOLEAN NOT NULL DEFAULT TRUE; UPDATE stm_action SET allowed = FALSE WHERE action_name = 'deny' OR action_name = 'drop' OR action_name = 'reject'; -Create table IF NOT EXISTS "rulebase" +Create table IF NOT EXISTS "rulebase" ( "id" SERIAL primary key, "name" Varchar NOT NULL, @@ -520,7 +520,7 @@ ALTER table "import_control" ADD COLUMN IF NOT EXISTS "is_full_import" BOOLEAN D ----------------------------------------------- -Create Table IF NOT EXISTS "rule_enforced_on_gateway" +Create Table IF NOT EXISTS "rule_enforced_on_gateway" ( "rule_id" Integer NOT NULL, "dev_id" Integer, -- NULL if rule is available for all gateways of its management @@ -538,7 +538,7 @@ Alter table "rule_enforced_on_gateway" add CONSTRAINT fk_rule_enforced_on_gatewa ALTER TABLE "rule_enforced_on_gateway" DROP CONSTRAINT IF EXISTS "fk_rule_enforced_on_gateway_created_import_control_control_id" CASCADE; -Alter table "rule_enforced_on_gateway" add CONSTRAINT fk_rule_enforced_on_gateway_created_import_control_control_id +Alter table "rule_enforced_on_gateway" add CONSTRAINT fk_rule_enforced_on_gateway_created_import_control_control_id foreign key ("created") references "import_control" ("control_id") on update restrict on delete cascade; ALTER TABLE "rule_enforced_on_gateway" @@ -548,7 +548,7 @@ ALTER TABLE "rule_enforced_on_gateway" ALTER TABLE "rule_enforced_on_gateway" DROP CONSTRAINT IF EXISTS "fk_rule_enforced_on_gateway_deleted_import_control_control_id" CASCADE; -Alter table "rule_enforced_on_gateway" add CONSTRAINT fk_rule_enforced_on_gateway_removed_import_control_control_id +Alter table "rule_enforced_on_gateway" add CONSTRAINT fk_rule_enforced_on_gateway_removed_import_control_control_id foreign key ("removed") references "import_control" ("control_id") on update restrict on delete cascade; ----------------------------------------------- @@ -559,8 +559,8 @@ RETURNS NUMERIC AS $$ FROM rule r WHERE r.mgm_id = mgmId and active AND r.rule_num_numeric > ( - SELECT rule_num_numeric - FROM rule + SELECT rule_num_numeric + FROM rule WHERE rule_uid = current_rule_uid AND mgm_id = mgmId AND active LIMIT 1 ) @@ -581,7 +581,7 @@ ALTER TABLE "rule" ADD CONSTRAINT fk_rule_rulebase_id FOREIGN KEY ("rulebase_id" -- Alter table "rule" add constraint "rule_metadata_dev_id_rule_uid_f_key" -- foreign key ("dev_id", "rule_uid", "rulebase_id") references "rule_metadata" ("dev_id", "rule_uid", "rulebase_id") on update restrict on delete cascade; --- Create table IF NOT EXISTS "rule_hit" +-- Create table IF NOT EXISTS "rule_hit" -- ( -- "rule_id" BIGINT NOT NULL, -- "rule_uid" VARCHAR NOT NULL, @@ -594,9 +594,9 @@ ALTER TABLE "rule" ADD CONSTRAINT fk_rule_rulebase_id FOREIGN KEY ("rulebase_id" -- Alter table "rule_hit" DROP CONSTRAINT IF EXISTS fk_rule_hit_rule_id; -- Alter table "rule_hit" DROP CONSTRAINT IF EXISTS fk_hit_gw_id; -- Alter table "rule_hit" DROP CONSTRAINT IF EXISTS fk_hit_metadata_id; --- Alter table "rule_hit" add CONSTRAINT fk_hit_rule_id foreign key ("rule_id") references "rule" ("rule_id") on update restrict on delete cascade; --- Alter table "rule_hit" add CONSTRAINT fk_hit_gw_id foreign key ("gw_id") references "device" ("dev_id") on update restrict on delete cascade; --- Alter table "rule_hit" add CONSTRAINT fk_hit_metadata_id foreign key ("metadata_id") references "rule_metadata" ("dev_id") on update restrict on delete cascade; +-- Alter table "rule_hit" add CONSTRAINT fk_hit_rule_id foreign key ("rule_id") references "rule" ("rule_id") on update restrict on delete cascade; +-- Alter table "rule_hit" add CONSTRAINT fk_hit_gw_id foreign key ("gw_id") references "device" ("dev_id") on update restrict on delete cascade; +-- Alter table "rule_hit" add CONSTRAINT fk_hit_metadata_id foreign key ("metadata_id") references "rule_metadata" ("dev_id") on update restrict on delete cascade; ----------------------------------------------- -- METADATA part @@ -610,7 +610,7 @@ Alter Table "rule_metadata" drop Constraint IF EXISTS "rule_metadata_alt_key"; -- ALTER TABLE rule_metadata DROP Constraint IF EXISTS "rule_metadata_rule_uid_unique"; -- ALTER TABLE rule_metadata ADD Constraint "rule_metadata_rule_uid_unique" unique ("rule_uid"); -- causes error: - -- None: FEHLER: kann Constraint rule_metadata_rule_uid_unique für Tabelle rule_metadata nicht löschen, weil andere Objekte davon abhängen\nDETAIL: + -- None: FEHLER: kann Constraint rule_metadata_rule_uid_unique für Tabelle rule_metadata nicht löschen, weil andere Objekte davon abhängen\nDETAIL: -- Constraint rule_metadata_rule_uid_f_key für Tabelle rule hängt von Index rule_metadata_rule_uid_unique ab\nHINT: Verwenden Sie DROP ... CASCADE, um die abhängigen Objekte ebenfalls zu löschen.\n"} ALTER TABLE rule_metadata DROP Constraint IF EXISTS "rule_metadata_rule_uid_unique" CASCADE; @@ -639,9 +639,9 @@ CREATE OR REPLACE VIEW v_rule_with_rule_owner AS WHERE NOT ow.id IS NULL GROUP BY r.rule_id, ow.id, ow.name, met.rule_last_certified, met.rule_last_certifier; -CREATE OR REPLACE VIEW v_rule_with_src_owner AS +CREATE OR REPLACE VIEW v_rule_with_src_owner AS SELECT - r.rule_id, ow.id as owner_id, ow.name as owner_name, + r.rule_id, ow.id as owner_id, ow.name as owner_name, CASE WHEN onw.ip = onw.ip_end THEN SPLIT_PART(CAST(onw.ip AS VARCHAR), '/', 1) -- Single IP overlap, removing netmask @@ -671,9 +671,9 @@ CREATE OR REPLACE VIEW v_rule_with_src_owner AS END GROUP BY r.rule_id, o.obj_ip, o.obj_ip_end, onw.ip, onw.ip_end, ow.id, ow.name, met.rule_last_certified, met.rule_last_certifier; -CREATE OR REPLACE VIEW v_rule_with_dst_owner AS - SELECT - r.rule_id, ow.id as owner_id, ow.name as owner_name, +CREATE OR REPLACE VIEW v_rule_with_dst_owner AS + SELECT + r.rule_id, ow.id as owner_id, ow.name as owner_name, CASE WHEN onw.ip = onw.ip_end THEN SPLIT_PART(CAST(onw.ip AS VARCHAR), '/', 1) -- Single IP overlap, removing netmask @@ -775,14 +775,14 @@ Alter table "rulebase_link" add CONSTRAINT unique_rulebase_link "to_rulebase_id", "created" ); - + ALTER TABLE "rulebase_link" DROP CONSTRAINT IF EXISTS "fk_rulebase_link_created_import_control_control_id" CASCADE; -Alter table "rulebase_link" add CONSTRAINT fk_rulebase_link_created_import_control_control_id +Alter table "rulebase_link" add CONSTRAINT fk_rulebase_link_created_import_control_control_id foreign key ("created") references "import_control" ("control_id") on update restrict on delete cascade; ALTER TABLE "rulebase_link" DROP CONSTRAINT IF EXISTS "fk_rulebase_link_removed_import_control_control_id" CASCADE; -Alter table "rulebase_link" add CONSTRAINT fk_rulebase_link_removed_import_control_control_id +Alter table "rulebase_link" add CONSTRAINT fk_rulebase_link_removed_import_control_control_id foreign key ("removed") references "import_control" ("control_id") on update restrict on delete cascade; insert into stm_link_type (id, name) VALUES (2, 'ordered') ON CONFLICT DO NOTHING; @@ -826,7 +826,7 @@ AS $function$ a_target_gateways VARCHAR[]; v_gw_name VARCHAR; BEGIN - FOR r_rulebase IN + FOR r_rulebase IN SELECT * FROM rulebase LOOP -- collect all device ids for this rulebase @@ -835,7 +835,7 @@ AS $function$ WHERE to_rulebase_id=r_rulebase.id ) INTO a_all_dev_ids_of_rulebase; - FOR r_rule IN + FOR r_rule IN SELECT rule_installon, rule_id FROM rule LOOP -- depending on install_on field: @@ -843,9 +843,9 @@ AS $function$ -- or just add specific gateway entries IF r_rule.rule_installon='Policy Targets' THEN -- need to find out other platforms equivivalent keywords - FOREACH i_dev_id IN ARRAY a_all_dev_ids_of_rulebase + FOREACH i_dev_id IN ARRAY a_all_dev_ids_of_rulebase LOOP - INSERT INTO rule_enforced_on_gateway (rule_id, dev_id, created) + INSERT INTO rule_enforced_on_gateway (rule_id, dev_id, created) VALUES (r_rule.rule_id, i_dev_id, (SELECT * FROM get_last_import_id_for_mgmt(r_rulebase.mgm_id))); END LOOP; ELSE @@ -856,13 +856,13 @@ AS $function$ SELECT ARRAY( SELECT string_to_array(r_rule.rule_installon, '|') ) INTO a_target_gateways; - FOREACH v_gw_name IN ARRAY a_target_gateways + FOREACH v_gw_name IN ARRAY a_target_gateways LOOP -- get dev_id for gw_name SELECT INTO i_dev_id dev_id FROM device WHERE dev_name=v_gw_name; IF FOUND THEN - INSERT INTO rule_enforced_on_gateway (rule_id, dev_id, created) - VALUES (r_rule.rule_id, i_dev_id, (SELECT * FROM get_last_import_id_for_mgmt(r_rulebase.mgm_id))); + INSERT INTO rule_enforced_on_gateway (rule_id, dev_id, created) + VALUES (r_rule.rule_id, i_dev_id, (SELECT * FROM get_last_import_id_for_mgmt(r_rulebase.mgm_id))); ELSE -- decide what to do with misses END IF; @@ -880,7 +880,7 @@ AS $function$ DECLARE r_dev RECORD; BEGIN - FOR r_dev IN + FOR r_dev IN -- TODO: deal with global rulebases here SELECT d.dev_id, rb.id as rulebase_id FROM device d LEFT JOIN rulebase rb ON (d.local_rulebase_name=rb.name) LOOP @@ -888,9 +888,9 @@ AS $function$ END LOOP; -- now we can add the "not null" constraint for rule_metadata.rulebase_id IF EXISTS ( - SELECT 1 + SELECT 1 FROM information_schema.columns - WHERE table_name = 'rule_metadata' + WHERE table_name = 'rule_metadata' AND column_name = 'rulebase_id' AND is_nullable = 'YES' ) THEN @@ -911,7 +911,7 @@ AS $function$ i_rulebase_id INTEGER; i_initial_rulebase_id INTEGER; BEGIN - FOR r_dev IN + FOR r_dev IN SELECT * FROM device LOOP -- find the id of the matching rulebase @@ -920,7 +920,7 @@ AS $function$ IF i_rulebase_id IS NOT NULL THEN SELECT INTO r_dev_null * FROM rulebase_link WHERE to_rulebase_id=i_rulebase_id AND gw_id=r_dev.dev_id AND removed IS NULL; IF NOT FOUND THEN - INSERT INTO rulebase_link (gw_id, from_rule_id, to_rulebase_id, created, link_type, is_initial) + INSERT INTO rulebase_link (gw_id, from_rule_id, to_rulebase_id, created, link_type, is_initial) VALUES (r_dev.dev_id, NULL, i_rulebase_id, (SELECT * FROM get_last_import_id_for_mgmt(r_dev.mgm_id)), 2, True) RETURNING id INTO i_initial_rulebase_id; -- when migrating, there cannot be more than one (the initial) rb per device END IF; @@ -935,7 +935,7 @@ AS $function$ SELECT INTO r_dev_null * FROM rulebase_link WHERE to_rulebase_id=i_rulebase_id AND gw_id=r_dev.dev_id; IF NOT FOUND THEN INSERT INTO rulebase_link (gw_id, from_rule_id, to_rulebase_id, created, link_type, is_initial) - VALUES (r_dev.dev_id, NULL, i_rulebase_id, (SELECT * FROM get_last_import_id_for_mgmt(r_dev.mgm_id)), 2, TRUE); + VALUES (r_dev.dev_id, NULL, i_rulebase_id, (SELECT * FROM get_last_import_id_for_mgmt(r_dev.mgm_id)), 2, TRUE); END IF; END IF; END IF; @@ -954,15 +954,15 @@ AS $function$ i_new_rulebase_id INTEGER; BEGIN - FOR r_dev IN + FOR r_dev IN SELECT * FROM device LOOP -- if rulebase does not exist yet: insert it SELECT INTO r_dev_null * FROM rulebase WHERE name=r_dev.local_rulebase_name; IF NOT FOUND AND r_dev.local_rulebase_name IS NOT NULL THEN -- first create rulebase entries - INSERT INTO rulebase (name, uid, mgm_id, is_global, created) - VALUES (r_dev.local_rulebase_name, r_dev.local_rulebase_name, r_dev.mgm_id, FALSE, 1) + INSERT INTO rulebase (name, uid, mgm_id, is_global, created) + VALUES (r_dev.local_rulebase_name, r_dev.local_rulebase_name, r_dev.mgm_id, FALSE, 1) RETURNING id INTO i_new_rulebase_id; -- now update references in all rules to the newly created rulebase UPDATE rule SET rulebase_id=i_new_rulebase_id WHERE dev_id=r_dev.dev_id; @@ -970,8 +970,8 @@ AS $function$ SELECT INTO r_dev_null * FROM rulebase WHERE name=r_dev.global_rulebase_name; IF NOT FOUND AND r_dev.global_rulebase_name IS NOT NULL THEN - INSERT INTO rulebase (name, uid, mgm_id, is_global, created) - VALUES (r_dev.global_rulebase_name, r_dev.global_rulebase_name, r_dev.mgm_id, TRUE, 1) + INSERT INTO rulebase (name, uid, mgm_id, is_global, created) + VALUES (r_dev.global_rulebase_name, r_dev.global_rulebase_name, r_dev.mgm_id, TRUE, 1) RETURNING id INTO i_new_rulebase_id; -- now update references in all rules to the newly created rulebase UPDATE rule SET rulebase_id=i_new_rulebase_id WHERE dev_id=r_dev.dev_id; @@ -979,9 +979,9 @@ AS $function$ END IF; END LOOP; - -- now check for remaining rules without rulebase_id + -- now check for remaining rules without rulebase_id -- TODO: decide how to deal with this - ONLY DUMMY SOLUTION FOR NOW - FOR r_rule IN + FOR r_rule IN SELECT * FROM rule WHERE rulebase_id IS NULL -- how do we deal with this? we simply pick the smallest rulebase id for now LOOP @@ -991,9 +991,9 @@ AS $function$ -- now we can add the "not null" constraint for rule.rulebase_id IF EXISTS ( - SELECT 1 + SELECT 1 FROM information_schema.columns - WHERE table_name = 'rule' + WHERE table_name = 'rule' AND column_name = 'rulebase_id' AND is_nullable = 'YES' ) THEN @@ -1003,7 +1003,7 @@ AS $function$ END; $function$; --- in this migration, in scenarios where a rulebase is used on more than one gateway, +-- in this migration, in scenarios where a rulebase is used on more than one gateway, -- only the rules of the first gw get a rulebase_id, the others (copies) will be deleted CREATE OR REPLACE FUNCTION migrateToRulebases() RETURNS VOID LANGUAGE plpgsql @@ -1119,7 +1119,7 @@ $$; -- add new compliance tables -CREATE TABLE IF NOT EXISTS compliance.policy +CREATE TABLE IF NOT EXISTS compliance.policy ( id SERIAL PRIMARY KEY, name TEXT, @@ -1197,60 +1197,60 @@ PRIMARY KEY (network_zone_id, ip_range_start, ip_range_end, created); -- add FKs -ALTER TABLE compliance.network_zone +ALTER TABLE compliance.network_zone DROP CONSTRAINT IF EXISTS compliance_criterion_network_zone_foreign_key; -ALTER TABLE compliance.network_zone -ADD CONSTRAINT compliance_criterion_network_zone_foreign_key -FOREIGN KEY (criterion_id) REFERENCES compliance.criterion(id) +ALTER TABLE compliance.network_zone +ADD CONSTRAINT compliance_criterion_network_zone_foreign_key +FOREIGN KEY (criterion_id) REFERENCES compliance.criterion(id) ON UPDATE RESTRICT ON DELETE CASCADE; -ALTER TABLE compliance.ip_range +ALTER TABLE compliance.ip_range DROP CONSTRAINT IF EXISTS compliance_criterion_ip_range_foreign_key; -ALTER TABLE compliance.ip_range -ADD CONSTRAINT compliance_criterion_ip_range_foreign_key -FOREIGN KEY (criterion_id) REFERENCES compliance.criterion(id) +ALTER TABLE compliance.ip_range +ADD CONSTRAINT compliance_criterion_ip_range_foreign_key +FOREIGN KEY (criterion_id) REFERENCES compliance.criterion(id) ON UPDATE RESTRICT ON DELETE CASCADE; -ALTER TABLE compliance.network_zone_communication +ALTER TABLE compliance.network_zone_communication DROP CONSTRAINT IF EXISTS compliance_criterion_network_zone_communication_foreign_key; -ALTER TABLE compliance.network_zone_communication -ADD CONSTRAINT compliance_criterion_network_zone_communication_foreign_key -FOREIGN KEY (criterion_id) REFERENCES compliance.criterion(id) +ALTER TABLE compliance.network_zone_communication +ADD CONSTRAINT compliance_criterion_network_zone_communication_foreign_key +FOREIGN KEY (criterion_id) REFERENCES compliance.criterion(id) ON UPDATE RESTRICT ON DELETE CASCADE; -ALTER TABLE compliance.policy_criterion +ALTER TABLE compliance.policy_criterion DROP CONSTRAINT IF EXISTS compliance_policy_policy_criterion_foreign_key; -ALTER TABLE compliance.policy_criterion -ADD CONSTRAINT compliance_policy_policy_criterion_foreign_key -FOREIGN KEY (policy_id) REFERENCES compliance.policy(id) +ALTER TABLE compliance.policy_criterion +ADD CONSTRAINT compliance_policy_policy_criterion_foreign_key +FOREIGN KEY (policy_id) REFERENCES compliance.policy(id) ON UPDATE RESTRICT ON DELETE CASCADE; -ALTER TABLE compliance.policy_criterion +ALTER TABLE compliance.policy_criterion DROP CONSTRAINT IF EXISTS compliance_criterion_policy_criterion_foreign_key; -ALTER TABLE compliance.policy_criterion -ADD CONSTRAINT compliance_criterion_policy_criterion_foreign_key -FOREIGN KEY (criterion_id) REFERENCES compliance.criterion(id) +ALTER TABLE compliance.policy_criterion +ADD CONSTRAINT compliance_criterion_policy_criterion_foreign_key +FOREIGN KEY (criterion_id) REFERENCES compliance.criterion(id) ON UPDATE RESTRICT ON DELETE CASCADE; -ALTER TABLE compliance.violation +ALTER TABLE compliance.violation DROP CONSTRAINT IF EXISTS compliance_policy_violation_foreign_key; -ALTER TABLE compliance.violation -ADD CONSTRAINT compliance_policy_violation_foreign_key -FOREIGN KEY (policy_id) REFERENCES compliance.policy(id) +ALTER TABLE compliance.violation +ADD CONSTRAINT compliance_policy_violation_foreign_key +FOREIGN KEY (policy_id) REFERENCES compliance.policy(id) ON UPDATE RESTRICT ON DELETE CASCADE; -ALTER TABLE compliance.violation +ALTER TABLE compliance.violation DROP CONSTRAINT IF EXISTS compliance_criterion_violation_foreign_key; -ALTER TABLE compliance.violation -ADD CONSTRAINT compliance_criterion_violation_foreign_key -FOREIGN KEY (criterion_id) REFERENCES compliance.criterion(id) +ALTER TABLE compliance.violation +ADD CONSTRAINT compliance_criterion_violation_foreign_key +FOREIGN KEY (criterion_id) REFERENCES compliance.criterion(id) ON UPDATE RESTRICT ON DELETE CASCADE; -ALTER TABLE compliance.violation +ALTER TABLE compliance.violation DROP CONSTRAINT IF EXISTS compliance_rule_violation_foreign_key; -ALTER TABLE compliance.violation -ADD CONSTRAINT compliance_rule_violation_foreign_key -FOREIGN KEY (rule_id) REFERENCES public.rule(rule_id) +ALTER TABLE compliance.violation +ADD CONSTRAINT compliance_rule_violation_foreign_key +FOREIGN KEY (rule_id) REFERENCES public.rule(rule_id) ON UPDATE RESTRICT ON DELETE CASCADE; -- add report type Compliance @@ -1268,13 +1268,13 @@ WHERE (removed IS NULL); -- add config parameter debugConfig if not exists -INSERT INTO config (config_key, config_value, config_user) +INSERT INTO config (config_key, config_value, config_user) VALUES ('debugConfig', '{"debugLevel":8, "extendedLogComplianceCheck":true, "extendedLogReportGeneration":true, "extendedLogScheduler":true}', 0) ON CONFLICT (config_key, config_user) DO NOTHING; -- add config parameter complianceCheckPolicy if not exists -INSERT INTO config (config_key, config_value, config_user) +INSERT INTO config (config_key, config_value, config_user) VALUES ('complianceCheckPolicy', '0', 0) ON CONFLICT (config_key, config_user) DO NOTHING; @@ -1299,10 +1299,10 @@ ALTER TABLE compliance.violation ADD COLUMN IF NOT EXISTS mgmt_uid TEXT; -- ); --- ALTER TABLE compliance.assessability_issue +-- ALTER TABLE compliance.assessability_issue -- DROP CONSTRAINT IF EXISTS compliance_assessability_issue_type_foreign_key; -- ALTER TABLE compliance.assessability_issue ADD CONSTRAINT compliance_assessability_issue_type_foreign_key FOREIGN KEY (type_id) REFERENCES compliance.assessability_issue_type(type_id) ON UPDATE RESTRICT ON DELETE CASCADE; --- ALTER TABLE compliance.assessability_issue +-- ALTER TABLE compliance.assessability_issue -- DROP CONSTRAINT IF EXISTS compliance_assessability_issue_violation_foreign_key; -- ALTER TABLE compliance.assessability_issue ADD CONSTRAINT compliance_assessability_issue_violation_foreign_key FOREIGN KEY (violation_id) REFERENCES compliance.violation(id) ON UPDATE RESTRICT ON DELETE CASCADE; @@ -1327,9 +1327,9 @@ END$$; -- add new report template for compliance: unresolved violations -INSERT INTO "report_template" ("report_filter","report_template_name","report_template_comment","report_template_owner", "report_parameters") +INSERT INTO "report_template" ("report_filter","report_template_name","report_template_comment","report_template_owner", "report_parameters") VALUES ('action=accept', - 'Compliance: Unresolved violations','T0108', 0, + 'Compliance: Unresolved violations','T0108', 0, '{"report_type":31,"device_filter":{"management":[]}, "time_filter": { "is_shortcut": true, @@ -1350,9 +1350,9 @@ ON CONFLICT (report_template_name) DO NOTHING; -- add new report template for compliance: diffs -INSERT INTO "report_template" ("report_filter","report_template_name","report_template_comment","report_template_owner", "report_parameters") +INSERT INTO "report_template" ("report_filter","report_template_name","report_template_comment","report_template_owner", "report_parameters") VALUES ('action=accept', - 'Compliance: Diffs','T0109', 0, + 'Compliance: Diffs','T0109', 0, '{"report_type":32,"device_filter":{"management":[]}, "time_filter": { "is_shortcut": true, @@ -1379,13 +1379,13 @@ ON CONFLICT (config_key, config_user) DO NOTHING; -- add parameter to persist report scheduler configs to config -INSERT INTO config (config_key, config_value, config_user) +INSERT INTO config (config_key, config_value, config_user) VALUES ('reportSchedulerConfig', '', 0) ON CONFLICT (config_key, config_user) DO NOTHING; -- add parameter to choose order by column of network matrix between name and id -INSERT INTO config (config_key, config_value, config_user) +INSERT INTO config (config_key, config_value, config_user) VALUES ('complianceCheckSortMatrixByID', 'false', 0) ON CONFLICT (config_key, config_user) DO NOTHING; @@ -1410,11 +1410,11 @@ INSERT INTO config (config_key, config_value, config_user) VALUES ('internalZone -- auto calculate special zone parameters -INSERT INTO config (config_key, config_value, config_user) +INSERT INTO config (config_key, config_value, config_user) VALUES ('autoCalculateInternetZone', 'true', 0) ON CONFLICT (config_key, config_user) DO NOTHING; -INSERT INTO config (config_key, config_value, config_user) +INSERT INTO config (config_key, config_value, config_user) VALUES ('autoCalculateUndefinedInternalZone', 'true', 0) ON CONFLICT (config_key, config_user) DO NOTHING; @@ -1494,7 +1494,7 @@ ON CONFLICT (config_key, config_user) DO NOTHING; -- TODO: fill all rulebase_id s and then add not null constraint --- TODOs +-- TODOs -- Rename table rulebase_on_gateways to gateway_rulebase to get correct plural gateway_rulebases in hasura @@ -1520,7 +1520,7 @@ ON CONFLICT (config_key, config_user) DO NOTHING; -- rule_last_hit -- rule_uid -- dev_id --- # here we do not have any rule details +-- # here we do not have any rule details -- } -- name: dev_name -- rulebase_on_gateways(order_by: {order_no: asc}) { @@ -1532,7 +1532,7 @@ ON CONFLICT (config_key, config_user) DO NOTHING; -- rules { -- mgm_id: mgm_id -- rule_metadatum { --- # here, the rule_metadata is always empty! +-- # here, the rule_metadata is always empty! -- rule_last_hit -- } -- ...ruleOverview @@ -1554,7 +1554,7 @@ ON CONFLICT (config_key, config_user) DO NOTHING; -- - statistics (optional: only count rules per gw which are active on gw) -- - adjust report tests (add column) --- import install on information (need to find out, where it is encoded) from +-- import install on information (need to find out, where it is encoded) from -- - fortimanger - simply add name of current gw? -- - fortios - simply add name of current gw? -- - others? - simply add name of current gw? @@ -1597,7 +1597,7 @@ ON CONFLICT (config_key, config_user) DO NOTHING; -- disabled in UI: -- recertification.razor -- in report.razor: --- - RSB +-- - RSB -- - TicketCreate Komponente -- 2024-10-09 planning @@ -1607,7 +1607,7 @@ ON CONFLICT (config_key, config_user) DO NOTHING; -- - instead get current config with every import -- - id for gateway needs to be fixated: --- - check point: +-- - check point: -- - read interface information from show-gateways-and-servers details-level=full -- - where to get routing infos? -- - optional: also get publish time per policy (push): @@ -1620,16 +1620,16 @@ ON CONFLICT (config_key, config_user) DO NOTHING; -- - goal: -- - in device table: -- - for CP only save policy-name per gateway (gotten from show-gateways-and-servers --- - in config file storage: +-- - in config file storage: -- - store all policies with the management rathen than with the gateway? -- - per gateway only store the ordered mapping gw --> policies -- - also allow for mapping a gateway to a policy from the manager's super-manager --- - TODO: set is_super_manager flag = true for MDS +-- - TODO: set is_super_manager flag = true for MDS -- { -- "ConfigFormat": "NORMALIZED", --- "ManagerSet": [ +-- "ManagerSet": [ -- { -- "ManagerUid": "6ae3760206b9bfbd2282b5964f6ea07869374f427533c72faa7418c28f7a77f2", -- "ManagerName": "schting2", @@ -1659,7 +1659,7 @@ ON CONFLICT (config_key, config_user) DO NOTHING; -- "second-layer", -- ":", -- ] --- EnforcedNatPolicyUids: List[str] = [] +-- EnforcedNatPolicyUids: List[str] = [] -- ] -- } -- ] @@ -1673,7 +1673,7 @@ ON CONFLICT (config_key, config_user) DO NOTHING; -- - get reports working -- - valentin: open issues for k01 UI problems -- - decide how to implement ordered layer (all must match) vs. e.g. global policies (first match) --- - allow for also importing native configs from file +-- - allow for also importing native configs from file -- TODOs after full importer migration @@ -1843,6 +1843,14 @@ insert into stm_dev_typ (dev_typ_id,dev_typ_name,dev_typ_version,dev_typ_manufac VALUES (29,'Cisco Asa on FirePower','9','Cisco','',false,true,false) ON CONFLICT (dev_typ_id) DO NOTHING; +CREATE TABLE IF NOT EXISTS refresh_tokens ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES uiuser(uiuser_id) ON DELETE CASCADE, + token_hash VARCHAR(88) UNIQUE NOT NULL, + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + revoked_at TIMESTAMP WITH TIME ZONE NULL +); DROP MATERIALIZED VIEW IF EXISTS view_rule_with_owner; CREATE MATERIALIZED VIEW view_rule_with_owner AS diff --git a/roles/lib/files/FWO.Api.Client/GraphQlApiConnection.cs b/roles/lib/files/FWO.Api.Client/GraphQlApiConnection.cs index 0539d195d0..3786728013 100644 --- a/roles/lib/files/FWO.Api.Client/GraphQlApiConnection.cs +++ b/roles/lib/files/FWO.Api.Client/GraphQlApiConnection.cs @@ -41,7 +41,7 @@ private void Initialize(string ApiServerUri) // 1 hour timeout graphQlClient.HttpClient.Timeout = new TimeSpan(1, 0, 0); - } + } public GraphQlApiConnection(string ApiServerUri, string jwt) { diff --git a/roles/lib/files/FWO.Api.Client/Queries/AuthQueries.cs b/roles/lib/files/FWO.Api.Client/Queries/AuthQueries.cs index 2679a53a9c..0d5ec16950 100644 --- a/roles/lib/files/FWO.Api.Client/Queries/AuthQueries.cs +++ b/roles/lib/files/FWO.Api.Client/Queries/AuthQueries.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.IO; using FWO.Logging; @@ -42,6 +42,11 @@ public class AuthQueries : Queries public static readonly string updateLdapConnection; public static readonly string deleteLdapConnection; + // Refresh Token Queries + public static readonly string storeRefreshToken; + public static readonly string getRefreshToken; + public static readonly string revokeRefreshToken; + static AuthQueries() { try @@ -81,6 +86,11 @@ static AuthQueries() newLdapConnection = GetQueryText("auth/newLdapConnection.graphql"); updateLdapConnection = GetQueryText("auth/updateLdapConnection.graphql"); deleteLdapConnection = GetQueryText("auth/deleteLdapConnection.graphql"); + + // Refresh Token Queries + storeRefreshToken = GetQueryText(Path.Combine("auth", "storeRefreshToken.graphql")); + getRefreshToken = GetQueryText(Path.Combine("auth", "getRefreshToken.graphql")); + revokeRefreshToken = GetQueryText(Path.Combine("auth", "revokeRefreshToken.graphql")); } catch (Exception exception) { diff --git a/roles/lib/files/FWO.Basics/GlobalConstants.cs b/roles/lib/files/FWO.Basics/GlobalConstants.cs index 3c6428a8f0..bb24167dbf 100644 --- a/roles/lib/files/FWO.Basics/GlobalConstants.cs +++ b/roles/lib/files/FWO.Basics/GlobalConstants.cs @@ -8,6 +8,7 @@ public struct GlobalConst public const string kFwoProdName = "fworch"; public const string kFwoBaseDir = "/usr/local/" + kFwoProdName; public const string kMainKeyFile = kFwoBaseDir + "/etc/secrets/main_key"; + public const string ASPNETCORE_ENVIRONMENT_LOCALTEST = "LOCALTEST"; public const string kEnglish = "English"; public const int kTenant0Id = 1; diff --git a/roles/lib/files/FWO.Config.Api/Data/ConfigData.cs b/roles/lib/files/FWO.Config.Api/Data/ConfigData.cs index 0e466147df..574ae114c9 100644 --- a/roles/lib/files/FWO.Config.Api/Data/ConfigData.cs +++ b/roles/lib/files/FWO.Config.Api/Data/ConfigData.cs @@ -523,6 +523,11 @@ public class ConfigData : ICloneable [JsonProperty("importedMatrixReadOnly"), JsonPropertyName("importedMatrixReadOnly")] public bool ImportedMatrixReadOnly { get; set; } = true; + [JsonProperty("accessTokenLifetimeHours"), JsonPropertyName("accessTokenLifetimeHours")] + public int AccessTokenLifetimeHours { get; set; } = 7; + + [JsonProperty("refreshTokenLifetimeDays"), JsonPropertyName("refreshTokenLifetimeDays")] + public int RefreshTokenLifetimeDays { get; set; } = 7; [JsonProperty("complianceCheckElementsPerFetch"), JsonPropertyName("complianceCheckElementsPerFetch")] public int ComplianceCheckElementsPerFetch { get; set; } = 500; diff --git a/roles/lib/files/FWO.Config.Api/UserConfig.cs b/roles/lib/files/FWO.Config.Api/UserConfig.cs index ce447a23d0..b154d3b8ca 100644 --- a/roles/lib/files/FWO.Config.Api/UserConfig.cs +++ b/roles/lib/files/FWO.Config.Api/UserConfig.cs @@ -1,4 +1,4 @@ -using System.Text.RegularExpressions; +using System.Text.RegularExpressions; using FWO.Basics; using FWO.Logging; using FWO.Config.Api.Data; @@ -190,7 +190,7 @@ public string GetApiText(string key) Match m = Regex.Match(key, pattern); if (m.Success) { - string msg = GetText(key[..5]); + string msg = GetText(m.Value); if (msg != GlobalConst.kUndefinedText) { text = msg; diff --git a/roles/lib/files/FWO.Config.File/ConfigFile.cs b/roles/lib/files/FWO.Config.File/ConfigFile.cs index 8155125d65..a3cfe0201f 100644 --- a/roles/lib/files/FWO.Config.File/ConfigFile.cs +++ b/roles/lib/files/FWO.Config.File/ConfigFile.cs @@ -1,4 +1,4 @@ -using FWO.Logging; +using FWO.Logging; using Microsoft.IdentityModel.Tokens; using System.Text.Json; using System.Text.Json.Serialization; @@ -156,17 +156,38 @@ private static ConfigValueType CriticalConfigValueLoaded(Config if (configValue == null) { Log.WriteError("Config value read", $"A necessary config value could not be found.", LogStackTrace: true); -#if RELEASE - Environment.Exit(1); // Exit with error -#endif - throw new ApplicationException("A necessary config value could not be found."); + + if (!IsTestEnvironment()) + { + Environment.Exit(1); // Only exit in production + return default!; + } + else + { + return default!; + } } - else + + return configValue; + } + + private static bool IsTestEnvironment() + { + // Check process name + string processName = System.Diagnostics.Process.GetCurrentProcess().ProcessName; + if (processName.Contains("testhost", StringComparison.OrdinalIgnoreCase) || + processName.Contains("vstest", StringComparison.OrdinalIgnoreCase)) { - return configValue; + return true; } + + // Check for test framework assemblies + return AppDomain.CurrentDomain.GetAssemblies() + .Any(a => a.FullName?.StartsWith("nunit.framework") == true + || a.FullName?.StartsWith("xunit") == true + || a.FullName?.StartsWith("Microsoft.VisualStudio.TestPlatform") == true); } - + private static void IgnoreExceptions(Action method) { try { method(); } catch (Exception e) { Log.WriteDebug("Config value", $"Config value could not be loaded. Error: {e.Message}"); } diff --git a/roles/lib/files/FWO.Data/Middleware/RefreshTokenInfo.cs b/roles/lib/files/FWO.Data/Middleware/RefreshTokenInfo.cs new file mode 100644 index 0000000000..61603fb6a6 --- /dev/null +++ b/roles/lib/files/FWO.Data/Middleware/RefreshTokenInfo.cs @@ -0,0 +1,20 @@ +using Newtonsoft.Json; +using System.Text.Json.Serialization; + +namespace FWO.Data.Middleware +{ + public class RefreshTokenInfo + { + [JsonPropertyName("user_id"), JsonProperty("user_id")] + public int UserId { get; set; } + + [JsonPropertyName("expires_at"), JsonProperty("expires_at")] + public DateTime ExpiresAt { get; set; } + + [JsonPropertyName("revoked_at"), JsonProperty("revoked_at")] + public DateTime? RevokedAt { get; set; } + + [JsonPropertyName("created_at"), JsonProperty("created_at")] + public DateTime CreatedAt { get; set; } + } +} diff --git a/roles/lib/files/FWO.Data/Middleware/RefreshTokenRequest.cs b/roles/lib/files/FWO.Data/Middleware/RefreshTokenRequest.cs new file mode 100644 index 0000000000..1e3426c28f --- /dev/null +++ b/roles/lib/files/FWO.Data/Middleware/RefreshTokenRequest.cs @@ -0,0 +1,7 @@ +namespace FWO.Data.Middleware +{ + public class RefreshTokenRequest + { + public string RefreshToken { get; set; } = ""; + } +} diff --git a/roles/lib/files/FWO.Data/Middleware/TokenPair.cs b/roles/lib/files/FWO.Data/Middleware/TokenPair.cs new file mode 100644 index 0000000000..935b284dd8 --- /dev/null +++ b/roles/lib/files/FWO.Data/Middleware/TokenPair.cs @@ -0,0 +1,10 @@ +namespace FWO.Data.Middleware +{ + public class TokenPair + { + public string AccessToken { get; set; } = ""; + public string RefreshToken { get; set; } = ""; + public DateTime AccessTokenExpires { get; set; } + public DateTime RefreshTokenExpires { get; set; } + } +} diff --git a/roles/lib/files/FWO.Middleware.Client/MiddlewareClient.cs b/roles/lib/files/FWO.Middleware.Client/MiddlewareClient.cs index 89259d25b0..138062cbd6 100644 --- a/roles/lib/files/FWO.Middleware.Client/MiddlewareClient.cs +++ b/roles/lib/files/FWO.Middleware.Client/MiddlewareClient.cs @@ -1,4 +1,4 @@ -using FWO.Api.Client; +using FWO.Api.Client; using FWO.Data.Middleware; using RestSharp; @@ -11,18 +11,18 @@ public class MiddlewareClient : RestApiClient, IDisposable public MiddlewareClient(string middlewareServerUri) : base(middlewareServerUri + "api/") { } - public async Task> AuthenticateUser(AuthenticationTokenGetParameters parameters) + public async Task> AuthenticateUser(AuthenticationTokenGetParameters parameters) { - RestRequest request = new ("AuthenticationToken/Get", Method.Post); + RestRequest request = new("AuthenticationToken/GetTokenPair", Method.Post); request.AddJsonBody(parameters); - return await restClient.ExecuteAsync(request); + return await restClient.ExecuteAsync(request); } - public async Task> CreateInitialJWT() + public async Task> CreateInitialJWT() { - RestRequest request = new ("AuthenticationToken/Get", Method.Post); + RestRequest request = new ("AuthenticationToken/GetTokenPair", Method.Post); request.AddJsonBody(new object()); - return await restClient.ExecuteAsync(request); + return await restClient.ExecuteAsync(request); } public async Task> TestConnection(LdapGetUpdateParameters parameters) @@ -254,6 +254,30 @@ public async Task> ImportCompianceMatrix(ComplianceImportMa return await restClient.ExecuteAsync(request); } + /// + /// Get a new token pair + /// + /// + /// + public virtual async Task> RefreshToken(RefreshTokenRequest parameters) + { + RestRequest request = new("AuthenticationToken/Refresh", Method.Post); + request.AddJsonBody(parameters); + return await restClient.ExecuteAsync(request); + } + + /// + /// Revoke a refresh token + /// + /// + /// + public virtual async Task RevokeRefreshToken(RefreshTokenRequest parameters) + { + RestRequest request = new("AuthenticationToken/Revoke", Method.Post); + request.AddJsonBody(parameters); + return await restClient.ExecuteAsync(request); + } + protected virtual void Dispose(bool disposing) { if (disposed) return; diff --git a/roles/middleware/files/FWO.Middleware.Server/Controllers/AuthenticationTokenController.cs b/roles/middleware/files/FWO.Middleware.Server/Controllers/AuthenticationTokenController.cs index d38bdf4a15..109fcd56a7 100644 --- a/roles/middleware/files/FWO.Middleware.Server/Controllers/AuthenticationTokenController.cs +++ b/roles/middleware/files/FWO.Middleware.Server/Controllers/AuthenticationTokenController.cs @@ -1,13 +1,17 @@ using FWO.Api.Client; using FWO.Api.Client.Queries; using FWO.Basics; +using FWO.Config.Api.Data; using FWO.Data; using FWO.Data.Middleware; using FWO.Logging; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Novell.Directory.Ldap; using System.Data; using System.Security.Authentication; +using System.Security.Cryptography; +using System.Text; namespace FWO.Middleware.Server.Controllers { @@ -33,16 +37,17 @@ public AuthenticationTokenController(JwtWriter jwtWriter, List ldaps, ApiC } /// - /// Generates an authentication token (jwt) given valid credentials. + /// Generates a new access and refresh token pair for a user based on the provided authentication parameters. /// - /// - /// Username (required) - /// Password (required) - /// - /// Credentials - /// Jwt, if credentials are vaild. - [HttpPost("Get")] - public async Task> GetAsync([FromBody] AuthenticationTokenGetParameters parameters) + /// This endpoint is typically used during user login to obtain tokens for subsequent + /// authenticated requests. The access token is stored in the database as a hash for security purposes. Ensure + /// that the credentials provided are valid to receive a token pair. + /// The authentication parameters containing the user's credentials. Must include a valid username and password. + /// Cannot be null. + /// An containing the generated access and refresh tokens if + /// authentication is successful; otherwise, a bad request result with an error message. + [HttpPost("GetTokenPair")] + public async Task> GetTokenPairAsync([FromBody] AuthenticationTokenGetParameters parameters) { try { @@ -53,17 +58,59 @@ public async Task> GetAsync([FromBody] AuthenticationTokenG string? username = parameters.Username; string? password = parameters.Password; - // Create User from given parameters / If user does not provide login data => anonymous login if (username != null && password != null) user = new UiUser { Name = username, Password = password }; } - AuthManager authManager = new (jwtWriter, ldaps, apiConnection); + AuthManager authManager = new(jwtWriter, ldaps, apiConnection); - // Authenticate user - string jwt = await authManager.AuthorizeUserAsync(user, validatePassword: true); + await authManager.AuthorizeUserAsync(user, validatePassword: true); - return Ok(jwt); + // Creates access and refresh token and stores the access token hash in DB + TokenPair tokenPair = await authManager.CreateTokenPair(user); + + return Ok(tokenPair); + } + catch (Exception ex) + { + Log.WriteError("Token Generation", "Error generating token pair", ex); + return BadRequest(ex.Message); + } + } + + /// + /// Generates a new token pair for a specified user, using administrator credentials for authorization. + /// + /// This endpoint is restricted to users with the admin role. The administrator's + /// credentials are validated before generating a token pair for the target user. The target user's password is + /// not required for this operation. + /// The parameters containing administrator credentials and the target user's information. Must include valid + /// admin username and password, as well as the target user's name or distinguished name. + /// An containing the generated token pair for the target user if the + /// operation succeeds; otherwise, a bad request result with an error message. + /// Thrown if the provided administrator credentials do not correspond to a user with the admin role. + [HttpPost("GetTokenPairForUser")] + public async Task> GetTokenPairForUser([FromBody] AuthenticationTokenGetForUserParameters parameters) + { + try + { + AuthManager authManager = new(jwtWriter, ldaps, apiConnection); + UiUser adminUser = new() { Name = parameters.AdminUsername, Password = parameters.AdminPassword }; + + await authManager.AuthorizeUserAsync(adminUser, validatePassword: true); + + if (!adminUser.Roles.Contains(Roles.Admin)) + { + throw new AuthenticationException("Provided credentials do not belong to a user with role admin."); + } + + UiUser targetUser = new() { Name = parameters.TargetUserName, Dn = parameters.TargetUserDn }; + + await authManager.AuthorizeUserAsync(targetUser, validatePassword: false, parameters.Lifetime); + + TokenPair tokenPair = await authManager.CreateTokenPair(targetUser, parameters.Lifetime); + + return Ok(tokenPair); } catch (Exception e) { @@ -72,57 +119,91 @@ public async Task> GetAsync([FromBody] AuthenticationTokenG } /// - /// Generates an authentication token (jwt) for the specified user given valid admin credentials. + /// Refreshes an access token using a valid refresh token. /// - /// - /// AdminUsername (required) - Example: "admin" - /// AdminPassword (required) - Example: "password" - /// Lifetime (optional) - Example: "365.12:02:00" ("days.hours:minutes:seconds") - /// TargetUserDn OR TargetUserName (required) - Example: "uid=demo_user,ou=tenant0,ou=operator,ou=user,dc=fworch,dc=internal" OR "demo_user" - /// - /// Admin Credentials, Lifetime, User - /// User jwt, if credentials are vaild. - [HttpPost("GetForUser")] - public async Task> GetAsyncForUser([FromBody] AuthenticationTokenGetForUserParameters parameters) + /// Refresh token request + /// New token pair if refresh token is valid + [HttpPost("Refresh")] + public async Task> RefreshToken([FromBody] RefreshTokenRequest request) { try { - string adminUsername = parameters.AdminUsername; - string adminPassword = parameters.AdminPassword; - TimeSpan lifetime = parameters.Lifetime; - string targetUserName = parameters.TargetUserName; - string targetUserDn = parameters.TargetUserDn; - - AuthManager authManager = new (jwtWriter, ldaps, apiConnection); - UiUser adminUser = new() { Name = adminUsername, Password = adminPassword }; - // Check if admin valids are valid - try + if (string.IsNullOrEmpty(request.RefreshToken)) { - await authManager.AuthorizeUserAsync(adminUser, validatePassword: true); - if (!adminUser.Roles.Contains(Roles.Admin)) - { - throw new AuthenticationException("Provided credentials do not belong to a user with role admin."); - } + return BadRequest("Refresh token is required"); } - catch (Exception e) + + AuthManager authManager = new(jwtWriter, ldaps, apiConnection); + + // Validate refresh token + RefreshTokenInfo? tokenInfo = await authManager.ValidateRefreshToken(request.RefreshToken); + + if (tokenInfo == null) { - throw new AuthenticationException("Error while validating admin credentials: " + e.Message); + return Unauthorized("Invalid or expired refresh token"); } - // Check if username is valid and generate jwt - try + + UiUser[] users = await apiConnection.SendQueryAsync(AuthQueries.getUserByDbId, new { userId = tokenInfo.UserId }); + UiUser? user = users.FirstOrDefault(); + + if (user == null) { - UiUser targetUser = new() { Name = targetUserName, Dn = targetUserDn }; - string jwt = await authManager.AuthorizeUserAsync(targetUser, validatePassword: false, lifetime); - return Ok(jwt); + return Unauthorized("User not found"); } - catch (Exception e) + + // Revoke the old refresh token (token rotation for security) + await authManager.RevokeRefreshToken(request.RefreshToken); + + // Create new token pair + TokenPair newTokens = await authManager.CreateTokenPair(user); + + Log.WriteInfo("Token Refresh", $"Successfully refreshed tokens for user {user.Name}"); + return Ok(newTokens); + } + catch (Exception ex) + { + Log.WriteError("Token Refresh", "Failed to refresh token", ex); + return BadRequest(ex.Message); + } + } + + /// + /// Revokes a refresh token, preventing it from being used for future token refreshes. + /// + /// The request containing the refresh token to revoke. + /// + /// An indicating success if the token is revoked; + /// otherwise, a bad request or unauthorized result with an error message. + /// + [HttpPost("Revoke")] + public async Task RevokeToken([FromBody] RefreshTokenRequest request) + { + try + { + if (string.IsNullOrEmpty(request.RefreshToken)) { - throw new AuthenticationException("Error while validating user credentials (user name): " + e.Message); + return BadRequest("Refresh token is required"); } + + AuthManager authManager = new(jwtWriter, ldaps, apiConnection); + + RefreshTokenInfo? tokenInfo = await authManager.ValidateRefreshToken(request.RefreshToken); + + if (tokenInfo == null) + { + return Unauthorized("Invalid or expired refresh token"); + } + + await authManager.RevokeRefreshToken(request.RefreshToken); + + Log.WriteInfo("Token Refresh", $"Successfully revoked refresh token"); + + return Ok(); } - catch (Exception e) + catch (Exception ex) { - return BadRequest(e.Message); + Log.WriteError("Token Refresh", "Failed to refresh token", ex); + return BadRequest(ex.Message); } } } @@ -188,12 +269,12 @@ public async Task> GetGroups(LdapEntry ldapUser, Ldap ldap) List userGroups = ldap.GetGroups(ldapUser); if (!ldap.IsInternal()) { - object groupsLock = new (); + object groupsLock = new(); List ldapRoleRequests = []; foreach (Ldap currentLdap in ldaps.Where(l => l.IsInternal())) { - ldapRoleRequests.Add(Task.Run(async() => + ldapRoleRequests.Add(Task.Run(async () => { // Get groups from current Ldap List currentGroups = await currentLdap.GetGroups([ldapUser.Dn]); @@ -220,7 +301,7 @@ public async Task> GetGroups(LdapEntry ldapUser, Ldap ldap) else { (LdapEntry? ldapEntry, Ldap? ldap) = await TryLoginAnywhere(user, validatePassword); - if (ldapEntry != null && ldap != null) + if (ldapEntry != null && ldap != null) { return (ldapEntry, ldap); } @@ -423,5 +504,118 @@ private static async Task AddDevices(ApiConnection conn, Tenant tenant) Management[] managementIds = await conn.SendQueryAsync(AuthQueries.getVisibleManagementIdsPerTenant, tenIdObj, "getVisibleManagementIdsPerTenant"); tenant.VisibleManagementIds = Array.ConvertAll(managementIds, management => management.Id); } + + /// + /// Validates a refresh token and returns token info if valid + /// + public async Task ValidateRefreshToken(string refreshToken) + { + try + { + string tokenHash = GenerateTokenHash(refreshToken); + + var queryVariables = new + { + tokenHash = tokenHash, + currentTime = DateTime.UtcNow + }; + + RefreshTokenInfo[] result = await apiConnection.SendQueryAsync(AuthQueries.getRefreshToken, queryVariables); + + return result?.FirstOrDefault(); + } + catch (Exception ex) + { + Log.WriteError("Token Validation", "Error validating refresh token", ex); + return null; + } + } + + /// + /// Stores a refresh token in the database + /// + public async Task StoreRefreshToken(int userId, string refreshToken, DateTime expiresAt) + { + try + { + string tokenHash = GenerateTokenHash(refreshToken); + + var mutationVariables = new + { + userId = userId, + tokenHash = tokenHash, + expiresAt = expiresAt, + createdAt = DateTime.UtcNow + }; + + await apiConnection.SendQueryAsync(AuthQueries.storeRefreshToken, mutationVariables); + } + catch (Exception ex) + { + Log.WriteError("Token Storage", "Error storing refresh token", ex); + throw; + } + } + + /// + /// Revokes a refresh token by marking it as revoked + /// + public async Task RevokeRefreshToken(string refreshToken) + { + try + { + string tokenHash = GenerateTokenHash(refreshToken); + + var mutationVariables = new + { + tokenHash = tokenHash, + revokedAt = DateTime.UtcNow + }; + + await apiConnection.SendQueryAsync(AuthQueries.revokeRefreshToken, mutationVariables); + } + catch (Exception ex) + { + Log.WriteError("Token Revocation", "Error revoking refresh token", ex); + throw; + } + } + + /// + /// Generates a SHA256 hash of the refresh token for secure storage + /// + private static string GenerateTokenHash(string token) + { + byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(token)); + return Convert.ToBase64String(hash); + } + + /// + /// Create access and refresh token pair for given user + /// + /// + /// + /// + public async Task CreateTokenPair(UiUser? user = null, TimeSpan? accessTokenLifetime = null) + { + UiUserHandler uiUserHandler = new(jwtWriter.CreateJWTMiddlewareServer()); + + TimeSpan accessLifetime = accessTokenLifetime ?? TimeSpan.FromHours(await uiUserHandler.GetExpirationTime(nameof(ConfigData.AccessTokenLifetimeHours))); + string accessToken = await jwtWriter.CreateJWT(user, accessLifetime); + + string refreshToken = JwtWriter.GenerateRefreshToken(); + int refreshTokenLifetimeDays = await uiUserHandler.GetExpirationTime(nameof(ConfigData.RefreshTokenLifetimeDays)); + DateTime refreshExpiry = DateTime.UtcNow.AddDays(refreshTokenLifetimeDays); + + await StoreRefreshToken(user?.DbId ?? 0, refreshToken, refreshExpiry); + + return new TokenPair + { + AccessToken = accessToken, + RefreshToken = refreshToken, + AccessTokenExpires = DateTime.UtcNow.Add(accessLifetime), + RefreshTokenExpires = refreshExpiry + }; + } } } diff --git a/roles/middleware/files/FWO.Middleware.Server/JwtWriter.cs b/roles/middleware/files/FWO.Middleware.Server/JwtWriter.cs index 050773165c..020eee90be 100644 --- a/roles/middleware/files/FWO.Middleware.Server/JwtWriter.cs +++ b/roles/middleware/files/FWO.Middleware.Server/JwtWriter.cs @@ -1,10 +1,12 @@ -using FWO.Basics; +using FWO.Basics; +using FWO.Config.Api.Data; using FWO.Data; using FWO.Logging; using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; +using System.Security.Cryptography; using System.Text.Json; namespace FWO.Middleware.Server @@ -40,7 +42,7 @@ public async Task CreateJWT(UiUser? user = null, TimeSpan? lifetime = nu UiUserHandler uiUserHandler = new (CreateJWTMiddlewareServer()); // if lifetime was speciefied use it, otherwise use standard lifetime - int jwtMinutesValid = (int)(lifetime?.TotalMinutes ?? await uiUserHandler.GetExpirationTime()); + int jwtMinutesValid = (int)(lifetime?.TotalMinutes ?? await uiUserHandler.GetExpirationTime(nameof(ConfigData.AccessTokenLifetimeHours))); ClaimsIdentity subject; if (user != null) @@ -120,9 +122,9 @@ private static ClaimsIdentity SetClaims(UiUser user) claimsIdentity.AddClaim(new Claim("x-hasura-user-id", user.DbId.ToString())); if (user.Dn != null && user.Dn.Length > 0) claimsIdentity.AddClaim(new Claim("x-hasura-uuid", user.Dn)); // UUID used for access to reports via API - + if (user.Tenant != null) - { + { claimsIdentity.AddClaim(new Claim("x-hasura-tenant-id", user.Tenant.Id.ToString())); if(user.Tenant.VisibleGatewayIds != null && user.Tenant.VisibleManagementIds != null) { @@ -178,5 +180,13 @@ private static string GetDefaultRole(UiUser user, List hasuraRolesList) } return defaultRole; } - } + + /// + /// Generates a cryptographically secure refresh token + /// + public static string GenerateRefreshToken() + { + return Convert.ToBase64String(RandomNumberGenerator.GetBytes(64)); + } + } } diff --git a/roles/middleware/files/FWO.Middleware.Server/Program.cs b/roles/middleware/files/FWO.Middleware.Server/Program.cs index ab35495f15..e70cc991a3 100644 --- a/roles/middleware/files/FWO.Middleware.Server/Program.cs +++ b/roles/middleware/files/FWO.Middleware.Server/Program.cs @@ -1,17 +1,15 @@ using FWO.Api.Client; using FWO.Api.Client.Queries; +using FWO.Basics; using FWO.Config.File; using FWO.Logging; using FWO.Middleware.Server; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.IdentityModel.Tokens; using Microsoft.OpenApi.Models; +using System.Diagnostics; using System.Reflection; -// Implicitly call static constructor so background lock process is started -// (static constructor is only called after class is used in any way) -Log.WriteInfo("Startup", "Starting FWO Middleware Server..."); - object changesLock = new(); // LOCK ReportScheduler reportScheduler; @@ -25,7 +23,19 @@ ComplianceCheckScheduler complianceCheckScheduler; WebApplicationBuilder builder = WebApplication.CreateBuilder(args); -builder.WebHost.UseUrls(ConfigFile.MiddlewareServerNativeUri ?? throw new ArgumentException("Missing middleware server url on startup.")); + +string? testHostUrl = Environment.GetEnvironmentVariable("APPLICATION_URL"); +bool isTestEnv = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == GlobalConst.ASPNETCORE_ENVIRONMENT_LOCALTEST; + +if (!string.IsNullOrEmpty(testHostUrl) && isTestEnv) +{ + builder.WebHost.UseUrls(testHostUrl); + Debug.WriteLine("FWO Middleware Server is running in test environment"); +} +else +{ + builder.WebHost.UseUrls(ConfigFile.MiddlewareServerNativeUri ?? throw new ArgumentException("Missing middleware server url on startup.")); +} // Create Token Generator JwtWriter jwtWriter = new(ConfigFile.JwtPrivateKey); @@ -59,53 +69,53 @@ // Create and start report scheduler await Task.Factory.StartNew(async () => { - reportScheduler = await ReportScheduler.CreateAsync(apiConnection, jwtWriter, connectedLdapsSubscription); + reportScheduler = await ReportScheduler.CreateAsync(apiConnection, jwtWriter, connectedLdapsSubscription); }, TaskCreationOptions.LongRunning); // Create and start auto disovery scheduler -await Task.Factory.StartNew(async() => +await Task.Factory.StartNew(async () => { autoDiscoverScheduler = await AutoDiscoverScheduler.CreateAsync(apiConnection); }, TaskCreationOptions.LongRunning); // Create and start daily check scheduler -await Task.Factory.StartNew(async() => +await Task.Factory.StartNew(async () => { dailyCheckScheduler = await DailyCheckScheduler.CreateAsync(apiConnection); }, TaskCreationOptions.LongRunning); // Create and start import app data scheduler -await Task.Factory.StartNew(async() => +await Task.Factory.StartNew(async () => { importAppDataScheduler = await ImportAppDataScheduler.CreateAsync(apiConnection); }, TaskCreationOptions.LongRunning); // Create and start import subnet data scheduler -await Task.Factory.StartNew(async() => +await Task.Factory.StartNew(async () => { importSubnetDataScheduler = await ImportIpDataScheduler.CreateAsync(apiConnection); }, TaskCreationOptions.LongRunning); // Create and start import change notify scheduler -await Task.Factory.StartNew(async() => +await Task.Factory.StartNew(async () => { importChangeNotifyScheduler = await ImportChangeNotifyScheduler.CreateAsync(apiConnection); }, TaskCreationOptions.LongRunning); // Create and start external request scheduler -await Task.Factory.StartNew(async() => +await Task.Factory.StartNew(async () => { externalRequestScheduler = await ExternalRequestScheduler.CreateAsync(apiConnection); }, TaskCreationOptions.LongRunning); // Create and start variance analysis scheduler -await Task.Factory.StartNew(async() => +await Task.Factory.StartNew(async () => { varianceAnalysisScheduler = await VarianceAnalysisScheduler.CreateAsync(apiConnection); }, TaskCreationOptions.LongRunning); // Create and start compliance check scheduler -await Task.Factory.StartNew(async() => +await Task.Factory.StartNew(async () => { complianceCheckScheduler = await ComplianceCheckScheduler.CreateAsync(apiConnection); }, TaskCreationOptions.LongRunning); @@ -114,8 +124,8 @@ await Task.Factory.StartNew(async() => builder.Services.AddControllers() .AddJsonOptions(jsonOptions => { - //jsonOptions.JsonSerializerOptions.PropertyNameCaseInsensitive = true; - jsonOptions.JsonSerializerOptions.PropertyNamingPolicy = null; + //jsonOptions.JsonSerializerOptions.PropertyNameCaseInsensitive = true; + jsonOptions.JsonSerializerOptions.PropertyNamingPolicy = null; }); builder.Services.AddSingleton(jwtWriter); @@ -175,3 +185,18 @@ await Task.Factory.StartNew(async() => app.MapControllers(); app.Run(); + +/// +/// Entry point for the FWO Middleware Server application to make it accessible for testing +/// +public partial class Program +{ + /// + /// Initializes a new instance of the class. + /// Protected constructor to allow partial class for testing. + /// + protected Program() + { + + } +} diff --git a/roles/middleware/files/FWO.Middleware.Server/UiUserHandler.cs b/roles/middleware/files/FWO.Middleware.Server/UiUserHandler.cs index c50965376b..7fb4d351c8 100644 --- a/roles/middleware/files/FWO.Middleware.Server/UiUserHandler.cs +++ b/roles/middleware/files/FWO.Middleware.Server/UiUserHandler.cs @@ -1,12 +1,16 @@ -using FWO.Api.Client; +using FWO.Api.Client; using FWO.Api.Client.Queries; -using FWO.Logging; -using FWO.Config.File; using FWO.Basics; +using FWO.Config.Api.Data; +using FWO.Config.File; using FWO.Data; -using System.Text.Json.Serialization; +using FWO.Logging; using Newtonsoft.Json; -using FWO.Config.Api.Data; +using System; +using System.Diagnostics; +using System.Reflection; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; using System.Text.RegularExpressions; namespace FWO.Middleware.Server @@ -37,21 +41,54 @@ public class UiUserHandler(string jwtToken) /// Get the configurated value for the session timeout. /// /// session timeout value in minutes - public async Task GetExpirationTime() + public async Task GetExpirationTime(string lifetimeKey) { int expirationTime = GlobalConst.kSessionExpirationTimeDefault; + + PropertyInfo? property = typeof(ConfigData).GetProperty(lifetimeKey); + JsonPropertyAttribute? jsonPropertyAttr = property?.GetCustomAttribute(); + + if (jsonPropertyAttr == null) + { + return expirationTime; + } + try { - List resultList = await apiConn.SendQueryAsync>(ConfigQueries.getConfigItemByKey, new { key = "sessionTimeout" }); + string? lifetimeKeyDBName = jsonPropertyAttr.PropertyName; + + if (string.IsNullOrEmpty(lifetimeKeyDBName)) + { + throw new ArgumentException("Lifetime key DB name is null or empty"); + } + + List resultList = await apiConn.SendQueryAsync>(ConfigQueries.getConfigItemByKey, new { key = lifetimeKeyDBName }); + if (resultList.Count > 0) { - expirationTime = resultList[0].ExpirationValue; + return resultList[0].ExpirationValue; + } + else + { + ConfigData defaultConfigValue = new(); + + //if no value is set in DB, take the default from config file and if that is not set, take the hardcoded constant + switch (lifetimeKey) + { + case nameof(ConfigData.AccessTokenLifetimeHours): + return (defaultConfigValue.AccessTokenLifetimeHours > 0) ? defaultConfigValue.AccessTokenLifetimeHours : expirationTime; + case nameof(ConfigData.RefreshTokenLifetimeDays): + return (defaultConfigValue.RefreshTokenLifetimeDays > 0) ? defaultConfigValue.RefreshTokenLifetimeDays : expirationTime; + default: + break; + } } } catch (Exception exeption) { Log.WriteError("Get ExpirationTime Error", $"Error while trying to find config value in database. Taking default value", exeption); } + return expirationTime; } @@ -104,7 +141,7 @@ public static async Task GetOwnershipsFromOwnerLdap(ApiConnection apiConn, UiUse { // if the user logging in is the main user for an application, add the ownerships List directOwnerships = await apiConn.SendQueryAsync>(OwnerQueries.getOwnersForUser, new { userDn = user.Dn }); - foreach (var owner in directOwnerships) + foreach (FwoOwner owner in directOwnerships) { user.Ownerships.Add(owner.Id); } @@ -131,7 +168,7 @@ public static async Task GetOwnershipsFromOwnerLdap(ApiConnection apiConn, UiUse List groupsOfUser = await ownerGroupLdap.GetGroupsOfUser(user.Name); - foreach (var group in groupsOfUser) + foreach (string group in groupsOfUser) { string groupName = new DistName(group).Group; if (!MatchesNamingConvention(groupName, namingConvention)) @@ -187,7 +224,7 @@ private static string ReplacePlaceholdersWithPattern(string input) } private static FwoOwner? FindOwnerWithMatchingGroupName(string groupName, List apps) { - foreach (var app in apps) + foreach (FwoOwner app in apps) { string[] groupDnParts = app.GroupDn.Split(',', StringSplitOptions.RemoveEmptyEntries); if (groupDnParts.Length == 0) diff --git a/roles/sample-auth-data/tasks/modify_ldap_tree.yml b/roles/sample-auth-data/tasks/modify_ldap_tree.yml index 9a2a362d5f..33b3da2d4c 100644 --- a/roles/sample-auth-data/tasks/modify_ldap_tree.yml +++ b/roles/sample-auth-data/tasks/modify_ldap_tree.yml @@ -7,6 +7,17 @@ - ../templates/tree_*.j2 become: true +- name: add or modify tree entries + command: "ldapmodify -H {{ openldap_url }} -D {{ openldap_superuser_dn }} -y {{ ldap_manager_pwd_file }} -x -f {{ middleware_ldif_dir }}/tree_{{ item }}.ldif" + loop: + - integration_testuser_jwt + register: ldap_result + failed_when: + - ldap_result.rc != 0 + - "'Already exists' not in ldap_result.stderr" + changed_when: ldap_result.rc == 0 + become: true + - name: add tree command: "ldapmodify -H {{ openldap_url }} -D {{ openldap_superuser_dn }} -y {{ ldap_manager_pwd_file }} -x -f {{ middleware_ldif_dir }}/tree_{{ item }}.ldif" loop: @@ -15,11 +26,12 @@ - sample_groups become: true -# only add roles and groups when not testing to avoid resudue from tests +#only add roles and groups when not testing to avoid resudue from tests - name: add tree command: "ldapmodify -H {{ openldap_url }} -D {{ openldap_superuser_dn }} -y {{ ldap_manager_pwd_file }} -x -f {{ middleware_ldif_dir }}/tree_{{ item }}.ldif" loop: - roles_for_sample_operators - groups_for_sample_operators + - roles_for_integration_testuser become: true when: sample_role_purpose is not match('test') diff --git a/roles/sample-auth-data/templates/tree_integration_testuser_jwt.ldif.j2 b/roles/sample-auth-data/templates/tree_integration_testuser_jwt.ldif.j2 new file mode 100644 index 0000000000..489d6f8ffe --- /dev/null +++ b/roles/sample-auth-data/templates/tree_integration_testuser_jwt.ldif.j2 @@ -0,0 +1,11 @@ + +dn: uid={{ integration_test_user_name }},ou=tenant0,ou=operator,ou=user,{{ openldap_path }} +changetype: {{ ldif_changetype }} +{% if ldif_changetype != 'delete' -%} +objectClass: top +objectclass: inetorgperson +cn: {{ integration_test_user_name }} +givenName: integration_test_user +sn: TestUser +userPassword: {{ integration_test_user_pw }} +{%- endif %} diff --git a/roles/sample-auth-data/templates/tree_roles_for_integration_testuser.ldif.j2 b/roles/sample-auth-data/templates/tree_roles_for_integration_testuser.ldif.j2 new file mode 100644 index 0000000000..7d061a286e --- /dev/null +++ b/roles/sample-auth-data/templates/tree_roles_for_integration_testuser.ldif.j2 @@ -0,0 +1,5 @@ + +dn: cn=reporter,ou=role,{{ openldap_path }} +changetype: modify +add: uniquemember +uniquemember: cn={{ integration_test_user_name }},ou=tenant0,ou=operator,ou=user,{{ openldap_path }} diff --git a/roles/tests-integration/handlers/main.yml b/roles/tests-integration/handlers/main.yml index 94330e2b16..249c2df0b7 100644 --- a/roles/tests-integration/handlers/main.yml +++ b/roles/tests-integration/handlers/main.yml @@ -28,6 +28,11 @@ become: true become_user: postgres +- name: delete integration test user from ldap + command: "ldapdelete -H {{ openldap_url }} -D {{ openldap_superuser_dn }} -y {{ ldap_manager_pwd_file }} -x uid={{ integration_test_user_name }},ou=tenant0,ou=operator,ou=user,{{ openldap_path }}" + listen: "test importer handler" + become: true + - name: find ldap entries with test_postfix command: "ldapsearch -H {{ openldap_url }} -D {{ openldap_superuser_dn }} -y {{ ldap_manager_pwd_file }} -b {{ openldap_path }} -x '(|(cn=*{{ sample_postfix }}*)(ou=*{{ sample_postfix }}*)(uid=*{{ sample_postfix }}*))'" register: ldap_entries_to_delete diff --git a/roles/tests-integration/tasks/test-api.yml b/roles/tests-integration/tasks/test-api.yml index d1c25fd480..2fd2985abf 100644 --- a/roles/tests-integration/tasks/test-api.yml +++ b/roles/tests-integration/tasks/test-api.yml @@ -49,7 +49,7 @@ - name: get sample jwt uri: - url: https://{{ middleware_hostname }}:{{ middleware_web_listener_port }}/api/AuthenticationToken/Get/ + url: https://{{ middleware_hostname }}:{{ middleware_web_listener_port }}/api/AuthenticationToken/GetTokenPair/ method: POST headers: Content-Type: application/json diff --git a/roles/tests-integration/tasks/test-auth.yml b/roles/tests-integration/tasks/test-auth.yml index 438b9aa138..e7ccc4a1e7 100644 --- a/roles/tests-integration/tasks/test-auth.yml +++ b/roles/tests-integration/tasks/test-auth.yml @@ -10,7 +10,7 @@ - name: middleware test get jwt valid creds ansible.builtin.uri: - url: "https://{{ middleware_hostname }}:{{ middleware_web_listener_port }}/api/AuthenticationToken/Get/" + url: "https://{{ middleware_hostname }}:{{ middleware_web_listener_port }}/api/AuthenticationToken/GetTokenPair/" method: POST headers: Content-Type: application/json @@ -27,28 +27,13 @@ - ansible.builtin.debug: var: sample_jwt -# --- JWT header decode (base64url -> json) --- -- name: extract raw jwt header and payload segments - ansible.builtin.set_fact: - jwt_header_raw: "{{ sample_jwt.content.split('.')[0] }}" - jwt_payload_raw: "{{ sample_jwt.content.split('.')[1] }}" - -# pad and translate base64url for header -- name: normalize/pad base64url header - ansible.builtin.set_fact: - jwt_header_b64: >- - {{ jwt_header_raw - | regex_replace('-', '+') - | regex_replace('_', '/') - ~ ('=' * ((4 - (jwt_header_raw | length % 4)) % 4)) }} - -- ansible.builtin.debug: { var: jwt_header_b64 } - -- name: decode + parse header json - ansible.builtin.set_fact: - jwt_header: "{{ jwt_header_b64 | b64decode | from_json }}" +- name: Extract JWT header and payload + set_fact: + jwt_header: "{{ (sample_jwt.json.AccessToken.split('.')[0] | b64decode | from_json) }}" + jwt_payload_raw: "{{ sample_jwt.json.AccessToken.split('.')[1] }}" - ansible.builtin.debug: { var: jwt_header } +- ansible.builtin.debug: { var: jwt_payload_raw } - name: pick header 'typ' ansible.builtin.set_fact: @@ -61,21 +46,13 @@ msg: "ERROR unexpected jwt test result (jwt_type does not match 'JWT'): {{ jwt_type }}" when: "jwt_type is not match('JWT')" -# --- JWT payload decode (base64url -> json) --- -- name: normalize/pad base64url payload - ansible.builtin.set_fact: - jwt_payload_b64: >- - {{ jwt_payload_raw - | regex_replace('-', '+') - | regex_replace('_', '/') - ~ ('=' * ((4 - (jwt_payload_raw | length % 4)) % 4)) }} +- name: Add padding to payload for base64 decoding + set_fact: + jwt_payload_padded: "{{ jwt_payload_raw }}{{ '=' * ((4 - (jwt_payload_raw | length) % 4) % 4) }}" -- ansible.builtin.debug: - var: jwt_payload_b64 - -- name: decode + parse payload json - ansible.builtin.set_fact: - jwt_payload: "{{ jwt_payload_b64 | b64decode | from_json }}" +- name: Decode JWT payload + set_fact: + jwt_payload: "{{ (jwt_payload_padded | b64decode | from_json) }}" - name: show jwt decoded payload ansible.builtin.debug: @@ -94,7 +71,7 @@ # --- negative test: wrong credentials --- - name: middleware test get jwt wrong creds ansible.builtin.uri: - url: "https://{{ middleware_hostname }}:{{ middleware_web_listener_port }}/api/AuthenticationToken/Get/" + url: "https://{{ middleware_hostname }}:{{ middleware_web_listener_port }}/api/AuthenticationToken/GetTokenPair/" method: POST headers: Content-Type: application/json diff --git a/roles/tests-unit/files/FWO.Test/AuthenticationTokenIntegrationTest.cs b/roles/tests-unit/files/FWO.Test/AuthenticationTokenIntegrationTest.cs new file mode 100644 index 0000000000..420c2d25a5 --- /dev/null +++ b/roles/tests-unit/files/FWO.Test/AuthenticationTokenIntegrationTest.cs @@ -0,0 +1,509 @@ +using NUnit.Framework; +using Microsoft.AspNetCore.Mvc.Testing; +using System.Net.Http.Json; +using FWO.Data.Middleware; +using FWO.Logging; +using System.IdentityModel.Tokens.Jwt; +using FWO.Test.DataGenerators; +using Microsoft.Extensions.Configuration; +using FWO.Config.File; +using Microsoft.AspNetCore.Hosting; +using System.Diagnostics; +using Microsoft.Extensions.DependencyInjection; +using System.Threading.Tasks; +using FWO.Basics; + +namespace FWO.Test +{ + /// + /// Integration tests for JWT authentication and refresh token functionality. + /// Tests the complete authentication flow including token generation, refresh, and revocation. + /// + [TestFixture] + internal class AuthenticationTokenIntegrationTest + { + private WebApplicationFactory? factory; + private HttpClient? client; + private JwtSecurityTokenHandler? tokenHandler; + + private TokenTestDataBuilder defaultCredentialsBuilder = null!; + //private TokenTestDataBuilder adminCredentialsBuilder = null!; // For future admin tests + + #region Setup and Teardown + + [OneTimeSetUp] + public void GlobalSetup() + { + bool isLocalTest = IsLocalTestEnvironment(); + bool isGitHubActions = IsRunningInGitHubActions(); + + Log.WriteInfo("Test Setup", $"Initializing JWT integration test environment (Local: {isLocalTest}, GitHub Actions: {isGitHubActions})"); + + // Initialize test credential + defaultCredentialsBuilder = new TokenTestDataBuilder() + .WithUsername("integration_user_jwt_refresh_test") + .WithPassword("testpassword"); + + //For tests with admin credentials needed (maybe in the future) + //adminCredentialsBuilder = new TokenTestDataBuilder() + // .WithTargetUser("admin") + // .WithUsername("admin") + // .WithPassword("adminpassword"); + + if (isLocalTest) + { + // Spin up local test server using WebApplicationFactory + Log.WriteInfo("Test Setup", "Creating WebApplicationFactory for local testing"); + factory = new WebApplicationFactory() + .WithWebHostBuilder(builder => + { + builder.ConfigureAppConfiguration((context, config) => + { + var testConfig = new Dictionary + { + { "Environment", GlobalConst.ASPNETCORE_ENVIRONMENT_LOCALTEST }, + { "Logging:LogLevel:Default", "Debug" } + }; + config.AddInMemoryCollection(testConfig); + }); + }); + + client = factory.CreateClient(); + } + else + { + string baseUrl = ConfigFile.MiddlewareServerNativeUri; + Log.WriteInfo("Test Setup", $"Connecting to external middleware server at: {baseUrl}"); + + client = new HttpClient + { + BaseAddress = new Uri(baseUrl) + }; + } + + tokenHandler = new JwtSecurityTokenHandler(); + } + + [OneTimeTearDown] + public void GlobalCleanup() + { + Log.WriteInfo("Test Cleanup", "Disposing JWT integration test resources"); + client?.Dispose(); + factory?.Dispose(); + } + + #endregion + + #region Environment Detection + + private static bool IsLocalTestEnvironment() + { + string? aspnetcoreEnv = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); + return aspnetcoreEnv?.Equals(GlobalConst.ASPNETCORE_ENVIRONMENT_LOCALTEST, StringComparison.OrdinalIgnoreCase) == true; + } + + private static bool IsRunningInGitHubActions() + { + string? sudoUser = Environment.GetEnvironmentVariable("SUDO_USER"); + string? ci = Environment.GetEnvironmentVariable("CI"); + + if (string.IsNullOrEmpty(sudoUser) || string.IsNullOrEmpty(ci)) + { + return false; + } + + return sudoUser.Equals("runner", StringComparison.OrdinalIgnoreCase) && + ci.Equals("true", StringComparison.OrdinalIgnoreCase); + } + + #endregion + + #region Token Generation Tests + + [Test] + [Category("Authentication")] + [Category("TokenGeneration")] + public async Task GetTokenPair_WithValidCredentials_ReturnsValidTokens() + { + // Arrange - use default credentials + AuthenticationTokenGetParameters parameters = defaultCredentialsBuilder.BuildGetParameters(); + + // Act + HttpResponseMessage response = await client!.PostAsJsonAsync("/api/AuthenticationToken/GetTokenPair", parameters); + + // Asserts + AuthTestHelpers.AssertSuccessResponse(response); + TokenPair? tokenPair = await response.Content.ReadFromJsonAsync(); + + AuthTestHelpers.AssertValidTokenPair(tokenPair); + AuthTestHelpers.AssertJwtStructure(tokenPair!.AccessToken, tokenHandler!); + AuthTestHelpers.AssertTokenClaims(tokenPair.AccessToken, parameters.Username!, tokenHandler!); + } + + [Test] + [Category("Authentication")] + [Category("TokenGeneration")] + public async Task GetTokenPair_WithInvalidCredentials_ReturnsBadRequest() + { + // Arrange - create invalid credentials + AuthenticationTokenGetParameters parameters = AuthTestHelpers.CreateInvalidCredentials(); + + // Act + HttpResponseMessage response = await client!.PostAsJsonAsync("/api/AuthenticationToken/GetTokenPair", parameters); + + // Assert + Assert.That(response.IsSuccessStatusCode, Is.False); + Assert.That(response.StatusCode, Is.EqualTo(System.Net.HttpStatusCode.BadRequest)); + } + + [Test] + [Category("Authentication")] + [Category("TokenGeneration")] + public async Task GetTokenPair_WithNullCredentials_ReturnsBadRequest() + { + // Arrange + AuthenticationTokenGetParameters? credentials = null; + + // Act + HttpResponseMessage response = await client!.PostAsJsonAsync("/api/AuthenticationToken/GetTokenPair", credentials); + + // Assert + Assert.That(response.IsSuccessStatusCode, Is.False); + } + + #endregion + + #region Token Refresh Tests + + [Test] + [Category("Authentication")] + [Category("TokenRefresh")] + public async Task RefreshToken_WithValidToken_ReturnsNewTokenPair() + { + // Arrange + TokenPair initialTokens = await GetValidTokenPair(); + await Task.Delay(1000); // Ensure different timestamps + + // Act + RefreshTokenRequest refreshRequest = new() { RefreshToken = initialTokens.RefreshToken }; + HttpResponseMessage response = await client!.PostAsJsonAsync("/api/AuthenticationToken/Refresh", refreshRequest); + + // Asserts + AuthTestHelpers.AssertSuccessResponse(response); + TokenPair? newTokens = await response.Content.ReadFromJsonAsync(); + + AuthTestHelpers.AssertValidTokenPair(newTokens); + AuthTestHelpers.AssertTokenRotation(initialTokens, newTokens!); + } + + [Test] + [Category("Authentication")] + [Category("TokenRefresh")] + public async Task RefreshToken_WithInvalidToken_ReturnsUnauthorized() + { + // Arrange + RefreshTokenRequest refreshRequest = new() { RefreshToken = "invalid_refresh_token_xyz" }; + + // Act + HttpResponseMessage response = await client!.PostAsJsonAsync("/api/AuthenticationToken/Refresh", refreshRequest); + + // Assert + Assert.That(response.StatusCode, Is.EqualTo(System.Net.HttpStatusCode.Unauthorized)); + } + + [Test] + [Category("Authentication")] + [Category("TokenRefresh")] + public async Task RefreshToken_WithEmptyToken_ReturnsBadRequest() + { + // Arrange + RefreshTokenRequest refreshRequest = new() { RefreshToken = "" }; + + // Act + HttpResponseMessage response = await client!.PostAsJsonAsync("/api/AuthenticationToken/Refresh", refreshRequest); + + // Assert + Assert.That(response.StatusCode, Is.EqualTo(System.Net.HttpStatusCode.BadRequest)); + } + + [Test] + [Category("Authentication")] + [Category("TokenRefresh")] + [Category("Security")] + public async Task RefreshToken_UsedTwice_SecondAttemptFails() + { + // Arrange + TokenPair initialTokens = await GetValidTokenPair(); + RefreshTokenRequest refreshRequest = new() { RefreshToken = initialTokens.RefreshToken }; + + // Act - First refresh (should succeed) + HttpResponseMessage firstResponse = await client!.PostAsJsonAsync("/api/AuthenticationToken/Refresh", refreshRequest); + + // Act - Second refresh with same token (should fail due to token rotation) + HttpResponseMessage secondResponse = await client!.PostAsJsonAsync("/api/AuthenticationToken/Refresh", refreshRequest); + + // Assert + Assert.That(firstResponse.IsSuccessStatusCode, Is.True); + Assert.That(secondResponse.StatusCode, Is.EqualTo(System.Net.HttpStatusCode.Unauthorized)); + } + + #endregion + + #region Token Revocation Tests + + [Test] + [Category("Authentication")] + [Category("TokenRevocation")] + [Category("Security")] + public async Task RevokeToken_WithValidToken_SucceedsAndPreventsRefresh() + { + // Arrange + TokenPair tokens = await GetValidTokenPair(); + + // Act - Revoke + RefreshTokenRequest revokeRequest = new() { RefreshToken = tokens.RefreshToken }; + HttpResponseMessage revokeResponse = await client!.PostAsJsonAsync("/api/AuthenticationToken/Revoke", revokeRequest); + + // Assert - Revocation succeeded + AuthTestHelpers.AssertSuccessResponse(revokeResponse); + + // Act - Try to refresh with revoked token + HttpResponseMessage refreshResponse = await client!.PostAsJsonAsync("/api/AuthenticationToken/Refresh", revokeRequest); + + // Assert - Refresh failed + Assert.That(refreshResponse.StatusCode, Is.EqualTo(System.Net.HttpStatusCode.Unauthorized)); + } + + [Test] + [Category("Authentication")] + [Category("TokenRevocation")] + public async Task RevokeToken_WithInvalidToken_ReturnsUnauthorized() + { + // Arrange + RefreshTokenRequest revokeRequest = new() { RefreshToken = "invalid_token" }; + + // Act + HttpResponseMessage response = await client!.PostAsJsonAsync("/api/AuthenticationToken/Revoke", revokeRequest); + + // Assert + Assert.That(response.StatusCode, Is.EqualTo(System.Net.HttpStatusCode.Unauthorized)); + } + + [Test] + [Category("Authentication")] + [Category("TokenRevocation")] + public async Task RevokeToken_AlreadyRevoked_ReturnsUnauthorized() + { + // Arrange + TokenPair tokens = await GetValidTokenPair(); + RefreshTokenRequest revokeRequest = new() { RefreshToken = tokens.RefreshToken }; + + // Act - First revocation + await client!.PostAsJsonAsync("/api/AuthenticationToken/Revoke", revokeRequest); + + // Act - Second revocation attempt + HttpResponseMessage response = await client!.PostAsJsonAsync("/api/AuthenticationToken/Revoke", revokeRequest); + + // Assert + Assert.That(response.StatusCode, Is.EqualTo(System.Net.HttpStatusCode.Unauthorized)); + } + + #endregion + + #region Admin Token Generation Tests + + [Test] + [Category("Authentication")] + [Category("AdminOperations")] + [Category("Security")] + public async Task GetForUser_WithNonAdminCredentials_ReturnsBadRequest() + { + // Arrange - use regular user credentials (not admin) + AuthenticationTokenGetForUserParameters parameters = defaultCredentialsBuilder + .WithTargetUser(defaultCredentialsBuilder.Username!) + .BuildGetForUserParameters(); + + // Act + HttpResponseMessage response = await client!.PostAsJsonAsync("/api/AuthenticationToken/GetTokenPairForUser", parameters); + + string responseText = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.That(response.StatusCode, Is.EqualTo(System.Net.HttpStatusCode.BadRequest)); + Assert.That(responseText.Contains("Provided credentials do not belong to a user with role admin"), Is.True); + } + + #endregion + + #region Token Expiration Tests + + [Test] + [Category("Authentication")] + [Category("TokenExpiration")] + public async Task TokenPair_ExpirationDates_AreSetCorrectly() + { + // Arrange & Act + TokenPair tokens = await GetValidTokenPair(); + + // Assert for expiration hierarchy + AuthTestHelpers.AssertTokenExpirationHierarchy(tokens); + } + + #endregion + + #region Helper Methods + + private async Task GetValidTokenPair() + { + // Use default credentials from GlobalSetup + AuthenticationTokenGetParameters parameters = defaultCredentialsBuilder.BuildGetParameters(); + HttpResponseMessage response = await client!.PostAsJsonAsync("/api/AuthenticationToken/GetTokenPair", parameters); + + if (!response.IsSuccessStatusCode) + { + throw new Exception($"Failed to get valid token pair for test setup. Status: {response.StatusCode}"); + } + + return (await response.Content.ReadFromJsonAsync())!; + } + + #endregion + + #region Token Workflow Tests + + [Test] + [Category("Authentication")] + [Category("TokenWorkflow")] + public async Task TokenWorkflow_CompleteLifecycle_GetRefreshRevoke_WorksCorrectly() + { + // Step 1: Get initial token pair + TokenPair initialTokens = await GetValidTokenPair(); + await Task.Delay(1000); // Ensure different timestamps + + // Assert initial tokens are valid + AuthTestHelpers.AssertValidTokenPair(initialTokens); + AuthTestHelpers.AssertJwtStructure(initialTokens.AccessToken, tokenHandler!); + + // Step 2: Refresh the token + RefreshTokenRequest refreshRequest = new() { RefreshToken = initialTokens.RefreshToken }; + HttpResponseMessage refreshResponse = await client!.PostAsJsonAsync("/api/AuthenticationToken/Refresh", refreshRequest); + + AuthTestHelpers.AssertSuccessResponse(refreshResponse); + TokenPair? refreshedTokens = await refreshResponse.Content.ReadFromJsonAsync(); + + // Assert refreshed tokens are valid and different + AuthTestHelpers.AssertValidTokenPair(refreshedTokens); + AuthTestHelpers.AssertTokenRotation(initialTokens, refreshedTokens!); + + // Step 3: Revoke the refreshed token + RefreshTokenRequest revokeRequest = new() { RefreshToken = refreshedTokens!.RefreshToken }; + HttpResponseMessage revokeResponse = await client!.PostAsJsonAsync("/api/AuthenticationToken/Revoke", revokeRequest); + + // Assert revocation succeeded + AuthTestHelpers.AssertSuccessResponse(revokeResponse); + + // Step 4: Verify token cannot be used after revocation + HttpResponseMessage postRevokeRefreshResponse = await client!.PostAsJsonAsync("/api/AuthenticationToken/Refresh", revokeRequest); + Assert.That(postRevokeRefreshResponse.StatusCode, Is.EqualTo(System.Net.HttpStatusCode.Unauthorized)); + } + + [Test] + [Category("Authentication")] + [Category("TokenWorkflow")] + public async Task TokenWorkflow_MultipleSequentialRefreshes_AllSucceed() + { + // Step 1: Get initial token pair + TokenPair currentTokens = await GetValidTokenPair(); + AuthTestHelpers.AssertValidTokenPair(currentTokens); + + // Step 2: Perform multiple sequential refreshes + const int refreshCount = 5; + for (int i = 0; i < refreshCount; i++) + { + await Task.Delay(1000); // Ensure different timestamps + + RefreshTokenRequest refreshRequest = new() { RefreshToken = currentTokens.RefreshToken }; + HttpResponseMessage refreshResponse = await client!.PostAsJsonAsync("/api/AuthenticationToken/Refresh", refreshRequest); + + // Assert each refresh succeeds + AuthTestHelpers.AssertSuccessResponse(refreshResponse); + TokenPair? newTokens = await refreshResponse.Content.ReadFromJsonAsync(); + + // Assert new tokens are valid and different + AuthTestHelpers.AssertValidTokenPair(newTokens); + AuthTestHelpers.AssertTokenRotation(currentTokens, newTokens!); + + // Update current tokens for next iteration + currentTokens = newTokens!; + } + + // Step 3: Final revocation to clean up + RefreshTokenRequest finalRevokeRequest = new() { RefreshToken = currentTokens.RefreshToken }; + HttpResponseMessage revokeResponse = await client!.PostAsJsonAsync("/api/AuthenticationToken/Revoke", finalRevokeRequest); + AuthTestHelpers.AssertSuccessResponse(revokeResponse); + } + + [Test] + [Category("Authentication")] + [Category("TokenWorkflow")] + [Category("Security")] + public async Task TokenWorkflow_OldRefreshTokenInvalidAfterRefresh_NewTokenWorks() + { + // Step 1: Get initial token pair + TokenPair initialTokens = await GetValidTokenPair(); + await Task.Delay(1000); + + // Step 2: Refresh to get new tokens + RefreshTokenRequest firstRefreshRequest = new() { RefreshToken = initialTokens.RefreshToken }; + HttpResponseMessage firstRefreshResponse = await client!.PostAsJsonAsync("/api/AuthenticationToken/Refresh", firstRefreshRequest); + + AuthTestHelpers.AssertSuccessResponse(firstRefreshResponse); + TokenPair? newTokens = await firstRefreshResponse.Content.ReadFromJsonAsync(); + AuthTestHelpers.AssertValidTokenPair(newTokens); + + // Step 3: Try to use old refresh token (should fail due to rotation) + HttpResponseMessage oldTokenRefreshResponse = await client!.PostAsJsonAsync("/api/AuthenticationToken/Refresh", firstRefreshRequest); + Assert.That(oldTokenRefreshResponse.StatusCode, Is.EqualTo(System.Net.HttpStatusCode.Unauthorized), + "Old refresh token should be invalid after rotation"); + + // Step 4: Verify new token still works + await Task.Delay(1000); + RefreshTokenRequest newRefreshRequest = new() { RefreshToken = newTokens!.RefreshToken }; + HttpResponseMessage newTokenRefreshResponse = await client!.PostAsJsonAsync("/api/AuthenticationToken/Refresh", newRefreshRequest); + + AuthTestHelpers.AssertSuccessResponse(newTokenRefreshResponse); + TokenPair? finalTokens = await newTokenRefreshResponse.Content.ReadFromJsonAsync(); + AuthTestHelpers.AssertValidTokenPair(finalTokens); + + // Cleanup + RefreshTokenRequest revokeRequest = new() { RefreshToken = finalTokens!.RefreshToken }; + await client!.PostAsJsonAsync("/api/AuthenticationToken/Revoke", revokeRequest); + } + + [Test] + [Category("Authentication")] + [Category("TokenWorkflow")] + [Category("Security")] + public async Task TokenWorkflow_GetTokenThenImmediateRevoke_CannotRefresh() + { + // Step 1: Get initial token pair + TokenPair tokens = await GetValidTokenPair(); + AuthTestHelpers.AssertValidTokenPair(tokens); + + // Step 2: Immediately revoke without any refresh + RefreshTokenRequest revokeRequest = new() { RefreshToken = tokens.RefreshToken }; + HttpResponseMessage revokeResponse = await client!.PostAsJsonAsync("/api/AuthenticationToken/Revoke", revokeRequest); + + AuthTestHelpers.AssertSuccessResponse(revokeResponse); + + // Step 3: Attempt to refresh revoked token (should fail) + RefreshTokenRequest refreshRequest = new() { RefreshToken = tokens.RefreshToken }; + HttpResponseMessage refreshResponse = await client!.PostAsJsonAsync("/api/AuthenticationToken/Refresh", refreshRequest); + + Assert.That(refreshResponse.StatusCode, Is.EqualTo(System.Net.HttpStatusCode.Unauthorized), + "Cannot refresh a token that has been revoked"); + } + + #endregion + } +} diff --git a/roles/tests-unit/files/FWO.Test/ConfigFileTest.cs b/roles/tests-unit/files/FWO.Test/ConfigFileTest.cs index fa80bf6440..007fc0f5e4 100644 --- a/roles/tests-unit/files/FWO.Test/ConfigFileTest.cs +++ b/roles/tests-unit/files/FWO.Test/ConfigFileTest.cs @@ -1,4 +1,4 @@ -using FWO.Config.File; +using FWO.Config.File; using FWO.Logging; using Microsoft.IdentityModel.Tokens; using NUnit.Framework; @@ -14,7 +14,6 @@ namespace FWO.Test { [TestFixture] - [Parallelizable] internal class ConfigFileTest { private const string configFileTestPath = "config_file.test"; @@ -127,8 +126,13 @@ public void MissingValueConfigFile() { CreateAndReadConfigFile(2, missingValueConfigFile); ClassicAssert.AreEqual("http://127.0.0.3:8880/", ConfigFile.MiddlewareServerNativeUri); - Assert.Catch(typeof(ApplicationException), () => { var _ = ConfigFile.MiddlewareServerUri; }); - Assert.Catch(typeof(ApplicationException), () => { var _ = ConfigFile.ApiServerUri; }); + + Assert.That(ConfigFile.MiddlewareServerUri, Is.Null); + Assert.That(ConfigFile.ApiServerUri, Is.Null); + + Assert.DoesNotThrow(() => { var _ = ConfigFile.MiddlewareServerUri; }); + Assert.DoesNotThrow(() => { var _ = ConfigFile.ApiServerUri; }); + ClassicAssert.AreEqual("500", ConfigFile.ProductVersion); } @@ -150,14 +154,18 @@ public void CorrectPrivateKey() public void IncorrectPublicKey() { CreateAndReadConfigFile(5, correctConfigFile, "", incorrectPublicKey); - Assert.Catch(typeof(ApplicationException), () => { var _ = ConfigFile.JwtPublicKey; }); + + Assert.That(ConfigFile.JwtPublicKey, Is.Null); + Assert.DoesNotThrow(() => { var _ = ConfigFile.JwtPublicKey; }); } [Test] public void IncorrectPrivateKey() { CreateAndReadConfigFile(6, correctConfigFile, incorrectPrivateKey, ""); - Assert.Catch(typeof(ApplicationException), () => { var _ = ConfigFile.JwtPrivateKey; }); + + Assert.That(ConfigFile.JwtPrivateKey, Is.Null); + Assert.DoesNotThrow(() => { var _ = ConfigFile.JwtPrivateKey; }); } [OneTimeTearDown] diff --git a/roles/tests-unit/files/FWO.Test/DataGenerators/AuthTestHelpers.cs b/roles/tests-unit/files/FWO.Test/DataGenerators/AuthTestHelpers.cs new file mode 100644 index 0000000000..e4534de634 --- /dev/null +++ b/roles/tests-unit/files/FWO.Test/DataGenerators/AuthTestHelpers.cs @@ -0,0 +1,113 @@ +using FWO.Data.Middleware; +using NUnit.Framework; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; + +namespace FWO.Test.DataGenerators +{ + /// + /// Helper utilities for authentication integration tests + /// + public static class AuthTestHelpers + { + /// + /// Creates invalid test credentials + /// + public static AuthenticationTokenGetParameters CreateInvalidCredentials() + { + return new AuthenticationTokenGetParameters + { + Username = "invaliduser", + Password = "wrongpassword" + }; + } + + /// + /// Asserts that HTTP response is successful + /// + public static void AssertSuccessResponse(HttpResponseMessage response) + { + Assert.That(response.IsSuccessStatusCode, Is.True, + $"Expected success status code, got {response.StatusCode}. Content: {response.Content.ReadAsStringAsync().Result}"); + } + + /// + /// Validates that a TokenPair has all required fields populated + /// + public static void AssertValidTokenPair(TokenPair? tokenPair) + { + Assert.That(tokenPair, Is.Not.Null, "TokenPair should not be null"); + Assert.That(tokenPair!.AccessToken, Is.Not.Null.And.Not.Empty, "AccessToken should not be null or empty"); + Assert.That(tokenPair.RefreshToken, Is.Not.Null.And.Not.Empty, "RefreshToken should not be null or empty"); + Assert.That(tokenPair.AccessTokenExpires, Is.Not.EqualTo(default(DateTime)), "AccessTokenExpires should be set"); + Assert.That(tokenPair.RefreshTokenExpires, Is.Not.EqualTo(default(DateTime)), "RefreshTokenExpires should be set"); + } + + /// + /// Validates JWT token structure (3 parts: header.payload.signature) + /// + public static void AssertJwtStructure(string jwt, JwtSecurityTokenHandler tokenHandler) + { + var parts = jwt.Split('.'); + Assert.That(parts.Length, Is.EqualTo(3), "JWT should have 3 parts (header.payload.signature)"); + + // Validate it's a parseable JWT + JwtSecurityToken token = tokenHandler.ReadJwtToken(jwt); + Assert.That(token, Is.Not.Null, "JWT should be parseable"); + Assert.That(token.Claims, Is.Not.Empty, "JWT should contain claims"); + } + + /// + /// Validates that JWT contains expected claims for the user + /// + public static void AssertTokenClaims(string jwt, string expectedUsername, JwtSecurityTokenHandler tokenHandler) + { + JwtSecurityToken token = tokenHandler.ReadJwtToken(jwt); + + // Check for required claims + Claim? nameClaim = token.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Name || c.Type == "unique_name"); + Assert.That(nameClaim, Is.Not.Null, "JWT should contain name claim"); + + if (!string.IsNullOrEmpty(expectedUsername)) + { + Assert.That(nameClaim!.Value, Is.EqualTo(expectedUsername), "JWT should contain correct username"); + } + + // Check for Hasura claims (specific to your implementation) + Claim? hasuraRolesClaim = token.Claims.FirstOrDefault(c => c.Type == "x-hasura-allowed-roles"); + Assert.That(hasuraRolesClaim, Is.Not.Null, "JWT should contain hasura roles claim"); + + Claim? defaultRoleClaim = token.Claims.FirstOrDefault(c => c.Type == "x-hasura-default-role"); + Assert.That(defaultRoleClaim, Is.Not.Null, "JWT should contain default role claim"); + } + + /// + /// Validates that token rotation occurred (new tokens are different from old) + /// + public static void AssertTokenRotation(TokenPair oldTokens, TokenPair newTokens) + { + Assert.That(newTokens.AccessToken, Is.Not.EqualTo(oldTokens.AccessToken), + "New access token should be different from old token"); + Assert.That(newTokens.RefreshToken, Is.Not.EqualTo(oldTokens.RefreshToken), + "New refresh token should be different from old token (token rotation)"); + } + + /// + /// Validates token expiration hierarchy (refresh token lives longer than access token) + /// + public static void AssertTokenExpirationHierarchy(TokenPair tokens) + { + Assert.That(tokens.AccessTokenExpires, Is.GreaterThan(DateTime.UtcNow), + "Access token should not be expired"); + Assert.That(tokens.RefreshTokenExpires, Is.GreaterThan(tokens.AccessTokenExpires), + "Refresh token should expire after access token"); + + // Refresh token should have longer lifetime than access token + TimeSpan accessLifetime = tokens.AccessTokenExpires - DateTime.UtcNow; + TimeSpan refreshLifetime = tokens.RefreshTokenExpires - DateTime.UtcNow; + + Assert.That(refreshLifetime, Is.GreaterThan(accessLifetime), + "Refresh token should have longer lifetime than access token"); + } + } +} diff --git a/roles/tests-unit/files/FWO.Test/DataGenerators/TokenTestDataBuilder.cs b/roles/tests-unit/files/FWO.Test/DataGenerators/TokenTestDataBuilder.cs new file mode 100644 index 0000000000..19e4674114 --- /dev/null +++ b/roles/tests-unit/files/FWO.Test/DataGenerators/TokenTestDataBuilder.cs @@ -0,0 +1,59 @@ +using FWO.Data.Middleware; + +namespace FWO.Test.DataGenerators +{ + /// + /// Builder pattern for creating test authentication data + /// + public class TokenTestDataBuilder + { + public string? Username { get; private set; } + public string? Password { get; private set; } + public string? TargetUserName { get; private set; } + public TimeSpan? Lifetime { get; private set; } + + public TokenTestDataBuilder WithUsername(string user) + { + Username = user; + return this; + } + + public TokenTestDataBuilder WithPassword(string pass) + { + Password = pass; + return this; + } + + public TokenTestDataBuilder WithTargetUser(string target) + { + TargetUserName = target; + return this; + } + + public TokenTestDataBuilder WithLifetime(TimeSpan time) + { + Lifetime = time; + return this; + } + + public AuthenticationTokenGetParameters BuildGetParameters() + { + return new AuthenticationTokenGetParameters + { + Username = Username, + Password = Password + }; + } + + public AuthenticationTokenGetForUserParameters BuildGetForUserParameters() + { + return new AuthenticationTokenGetForUserParameters + { + AdminUsername = Username!, + AdminPassword = Password!, + TargetUserName = TargetUserName!, + Lifetime = Lifetime ?? TimeSpan.FromHours(24) + }; + } + } +} diff --git a/roles/tests-unit/files/FWO.Test/FWO.Test.csproj b/roles/tests-unit/files/FWO.Test/FWO.Test.csproj index a64da30f44..3edde96a48 100644 --- a/roles/tests-unit/files/FWO.Test/FWO.Test.csproj +++ b/roles/tests-unit/files/FWO.Test/FWO.Test.csproj @@ -11,6 +11,7 @@ + diff --git a/roles/tests-unit/files/FWO.Test/LockTest.cs b/roles/tests-unit/files/FWO.Test/LockTest.cs index 24fd05d4c4..e9d4a59a3f 100644 --- a/roles/tests-unit/files/FWO.Test/LockTest.cs +++ b/roles/tests-unit/files/FWO.Test/LockTest.cs @@ -1,4 +1,4 @@ -using FWO.Logging; +using FWO.Logging; using NUnit.Framework; using NUnit.Framework.Legacy; using System; @@ -7,7 +7,6 @@ namespace FWO.Test { [TestFixture] - [Parallelizable] public class LockTest { private string lockFilePath = $"/var/fworch/lock/{Assembly.GetEntryAssembly()?.GetName().Name}_log.lock"; @@ -123,7 +122,7 @@ private static async Task ExecuteFileAction(Func action) try { await action(); - success = true; + success = true; } catch (IOException) { diff --git a/roles/tests-unit/files/FWO.Test/Mocks/MockMiddlewareClient.cs b/roles/tests-unit/files/FWO.Test/Mocks/MockMiddlewareClient.cs new file mode 100644 index 0000000000..316c7dc1d5 --- /dev/null +++ b/roles/tests-unit/files/FWO.Test/Mocks/MockMiddlewareClient.cs @@ -0,0 +1,96 @@ +using FWO.Data.Middleware; +using FWO.Middleware.Client; +using RestSharp; +using System.Net; + +namespace FWO.Test.Mocks +{ + /// + /// Mock implementation of MiddlewareClient for testing purposes + /// + public class MockMiddlewareClient : MiddlewareClient + { + public TokenPair? NextRefreshTokenResponse { get; set; } + public bool ShouldRefreshSucceed { get; set; } = true; + public bool ShouldRevokeSucceed { get; set; } = true; + public int RefreshTokenCallCount { get; private set; } + public int RevokeRefreshTokenCallCount { get; private set; } + public RefreshTokenRequest? LastRefreshRequest { get; private set; } + public RefreshTokenRequest? LastRevokeRequest { get; private set; } + + public MockMiddlewareClient() : base("http://localhost/") + { + } + + public override async Task> RefreshToken(RefreshTokenRequest parameters) + { + RefreshTokenCallCount++; + LastRefreshRequest = parameters; + + await Task.CompletedTask; + + RestRequest request = new(); + + if (ShouldRefreshSucceed && NextRefreshTokenResponse != null) + { + RestResponse response = new(request) + { + StatusCode = HttpStatusCode.OK, + Data = NextRefreshTokenResponse, + ResponseStatus = ResponseStatus.Completed, + Content = System.Text.Json.JsonSerializer.Serialize(NextRefreshTokenResponse), + IsSuccessStatusCode = true + }; + return response; + } + + RestResponse failResponse = new(request) + { + StatusCode = HttpStatusCode.Unauthorized, + ErrorMessage = "Refresh token failed", + ResponseStatus = ResponseStatus.Error, + IsSuccessStatusCode = false + }; + return failResponse; + } + + public override async Task RevokeRefreshToken(RefreshTokenRequest parameters) + { + RevokeRefreshTokenCallCount++; + LastRevokeRequest = parameters; + + await Task.CompletedTask; + + RestRequest request = new(); + + if (ShouldRevokeSucceed) + { + return new RestResponse(request) + { + StatusCode = HttpStatusCode.OK, + ResponseStatus = ResponseStatus.Completed, + IsSuccessStatusCode = true + }; + } + + return new RestResponse(request) + { + StatusCode = HttpStatusCode.BadRequest, + ErrorMessage = "Revoke token failed", + ResponseStatus = ResponseStatus.Error, + IsSuccessStatusCode = false + }; + } + + public void Reset() + { + RefreshTokenCallCount = 0; + RevokeRefreshTokenCallCount = 0; + LastRefreshRequest = null; + LastRevokeRequest = null; + NextRefreshTokenResponse = null; + ShouldRefreshSucceed = true; + ShouldRevokeSucceed = true; + } + } +} diff --git a/roles/tests-unit/files/FWO.Test/Mocks/MockProtectedSessionStorage.cs b/roles/tests-unit/files/FWO.Test/Mocks/MockProtectedSessionStorage.cs new file mode 100644 index 0000000000..0e6c51a9aa --- /dev/null +++ b/roles/tests-unit/files/FWO.Test/Mocks/MockProtectedSessionStorage.cs @@ -0,0 +1,71 @@ +using Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage; +using System.Reflection; +using FWO.Ui.Services; + +namespace FWO.Test.Mocks +{ + /// + /// Mock implementation of ISessionStorage for testing purposes + /// + public class MockProtectedSessionStorage : ISessionStorage + { + private readonly Dictionary storage = new(); + + public MockProtectedSessionStorage() + { + } + + public ValueTask> GetAsync(string key) + { + if (storage.TryGetValue(key, out var value) && value is TValue typedValue) + { + var result = CreateSuccessResult(typedValue); + return ValueTask.FromResult(result); + } + + var emptyResult = CreateFailureResult(); + return ValueTask.FromResult(emptyResult); + } + + public ValueTask SetAsync(string key, object value) + { + storage[key] = value; + return ValueTask.CompletedTask; + } + + public ValueTask DeleteAsync(string key) + { + storage.Remove(key); + return ValueTask.CompletedTask; + } + + public void Clear() + { + storage.Clear(); + } + + public bool ContainsKey(string key) + { + return storage.ContainsKey(key); + } + + public int Count => storage.Count; + + private static ProtectedBrowserStorageResult CreateSuccessResult(TValue value) + { + // Use the internal constructor via reflection + var constructor = typeof(ProtectedBrowserStorageResult).GetConstructors( + BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)[0]; + + return (ProtectedBrowserStorageResult)constructor.Invoke([true, value]); + } + + private static ProtectedBrowserStorageResult CreateFailureResult() + { + var constructor = typeof(ProtectedBrowserStorageResult).GetConstructors( + BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)[0]; + + return (ProtectedBrowserStorageResult)constructor.Invoke([false, default(TValue)]); + } + } +} diff --git a/roles/tests-unit/files/FWO.Test/TokenServiceTest.cs b/roles/tests-unit/files/FWO.Test/TokenServiceTest.cs new file mode 100644 index 0000000000..746485b5f4 --- /dev/null +++ b/roles/tests-unit/files/FWO.Test/TokenServiceTest.cs @@ -0,0 +1,586 @@ +using NUnit.Framework; +using FWO.Ui.Services; +using FWO.Data.Middleware; +using FWO.Test.Mocks; +using System.IdentityModel.Tokens.Jwt; +using Microsoft.IdentityModel.Tokens; +using System.Security.Claims; +using System.Text; +using FWO.Middleware.Client; + +namespace FWO.Test +{ + /// + /// Unit tests for TokenService using custom mock implementations. + /// + [TestFixture] + public class TokenServiceTest + { + private MockMiddlewareClient? mockMiddlewareClient; + private MockProtectedSessionStorage? mockSessionStorage; + private TokenService? tokenService; + + private const string TEST_ACCESS_TOKEN = "test.access.token"; + private const string TEST_REFRESH_TOKEN = "test_refresh_token_12345"; + + [SetUp] + public void Setup() + { + mockMiddlewareClient = new MockMiddlewareClient(); + mockSessionStorage = new MockProtectedSessionStorage(); + tokenService = new TokenService(mockMiddlewareClient, mockSessionStorage); + } + + [TearDown] + public void TearDown() + { + mockSessionStorage?.Clear(); + mockMiddlewareClient?.Reset(); + } + + #region SetTokenPair Tests + + [Test] + public async Task SetTokenPair_ShouldStoreTokenPairInSessionStorage() + { + // Arrange + TokenPair tokenPair = CreateTestTokenPair(); + + // Act + await tokenService!.SetTokenPair(tokenPair); + + // Assert + Assert.That(mockSessionStorage!.ContainsKey("token_pair"), Is.True); + } + + [Test] + public async Task SetTokenPair_ShouldUpdateExistingTokenPair() + { + // Arrange + TokenPair oldTokenPair = CreateTestTokenPair(); + TokenPair newTokenPair = new() + { + AccessToken = "new.access.token", + RefreshToken = "new_refresh_token", + AccessTokenExpires = DateTime.UtcNow.AddHours(2), + RefreshTokenExpires = DateTime.UtcNow.AddDays(14) + }; + + // Act + await tokenService!.SetTokenPair(oldTokenPair); + await tokenService.SetTokenPair(newTokenPair); + + // Assert + string? storedToken = await tokenService.GetAccessTokenAsync(); + Assert.That(storedToken, Is.EqualTo("new.access.token")); + } + + #endregion + + #region GetAccessTokenAsync Tests + + [Test] + public async Task GetAccessTokenAsync_WhenNoTokenExists_ShouldReturnNull() + { + // Act + string? result = await tokenService!.GetAccessTokenAsync(); + + // Assert + Assert.That(result, Is.Null); + } + + [Test] + public async Task GetAccessTokenAsync_WhenTokenExists_ShouldReturnAccessToken() + { + // Arrange + TokenPair tokenPair = CreateTestTokenPair(); + await tokenService!.SetTokenPair(tokenPair); + + // Act + string? result = await tokenService.GetAccessTokenAsync(); + + // Assert + Assert.That(result, Is.EqualTo(TEST_ACCESS_TOKEN)); + } + + [Test] + public async Task GetAccessTokenAsync_WhenCalledMultipleTimes_ShouldReturnSameToken() + { + // Arrange + TokenPair tokenPair = CreateTestTokenPair(); + await tokenService!.SetTokenPair(tokenPair); + + // Act + string? result1 = await tokenService.GetAccessTokenAsync(); + string? result2 = await tokenService.GetAccessTokenAsync(); + string? result3 = await tokenService.GetAccessTokenAsync(); + + // Assert + Assert.That(result1, Is.EqualTo(result2)); + Assert.That(result2, Is.EqualTo(result3)); + Assert.That(result1, Is.EqualTo(TEST_ACCESS_TOKEN)); + } + + #endregion + + #region IsAccessTokenExpired Tests + + [Test] + public async Task IsAccessTokenExpired_WhenNoTokenExists_ShouldReturnTrue() + { + // Act + bool result = await tokenService!.IsAccessTokenExpired(); + + // Assert + Assert.That(result, Is.True); + } + + [Test] + public async Task IsAccessTokenExpired_WhenTokenIsNull_ShouldReturnTrue() + { + // Arrange + TokenPair tokenPair = new() + { + AccessToken = "", + RefreshToken = TEST_REFRESH_TOKEN, + AccessTokenExpires = DateTime.UtcNow.AddHours(1), + RefreshTokenExpires = DateTime.UtcNow.AddDays(7) + }; + await tokenService!.SetTokenPair(tokenPair); + + // Act + bool result = await tokenService.IsAccessTokenExpired(); + + // Assert + Assert.That(result, Is.True); + } + + [Test] + public async Task IsAccessTokenExpired_WhenTokenIsValidAndNotExpired_ShouldReturnFalse() + { + // Arrange + string validToken = GenerateJwtToken(DateTime.UtcNow.AddHours(2)); + TokenPair tokenPair = new() + { + AccessToken = validToken, + RefreshToken = TEST_REFRESH_TOKEN, + AccessTokenExpires = DateTime.UtcNow.AddHours(2), + RefreshTokenExpires = DateTime.UtcNow.AddDays(7) + }; + await tokenService!.SetTokenPair(tokenPair); + + // Act + bool result = await tokenService.IsAccessTokenExpired(); + + // Assert + Assert.That(result, Is.False); + } + + [Test] + public async Task IsAccessTokenExpired_WhenTokenExpiresInLessThanOneMinute_ShouldReturnTrue() + { + // Arrange + string expiringToken = GenerateJwtToken(DateTime.UtcNow.AddSeconds(30)); + TokenPair tokenPair = new() + { + AccessToken = expiringToken, + RefreshToken = TEST_REFRESH_TOKEN, + AccessTokenExpires = DateTime.UtcNow.AddSeconds(30), + RefreshTokenExpires = DateTime.UtcNow.AddDays(7) + }; + await tokenService!.SetTokenPair(tokenPair); + + // Act + bool result = await tokenService.IsAccessTokenExpired(); + + // Assert + Assert.That(result, Is.True); + } + + [Test] + public async Task IsAccessTokenExpired_WhenTokenIsAlreadyExpired_ShouldReturnTrue() + { + // Arrange + string expiredToken = GenerateJwtToken(DateTime.UtcNow.AddHours(-1)); + TokenPair tokenPair = new() + { + AccessToken = expiredToken, + RefreshToken = TEST_REFRESH_TOKEN, + AccessTokenExpires = DateTime.UtcNow.AddHours(-1), + RefreshTokenExpires = DateTime.UtcNow.AddDays(7) + }; + await tokenService!.SetTokenPair(tokenPair); + + // Act + bool result = await tokenService.IsAccessTokenExpired(); + + // Assert + Assert.That(result, Is.True); + } + + [Test] + public async Task IsAccessTokenExpired_WhenTokenIsInvalid_ShouldReturnTrue() + { + // Arrange + TokenPair tokenPair = new() + { + AccessToken = "invalid.jwt.token", + RefreshToken = TEST_REFRESH_TOKEN, + AccessTokenExpires = DateTime.UtcNow.AddHours(1), + RefreshTokenExpires = DateTime.UtcNow.AddDays(7) + }; + await tokenService!.SetTokenPair(tokenPair); + + // Act + bool result = await tokenService.IsAccessTokenExpired(); + + // Assert + Assert.That(result, Is.True); + } + + #endregion + + #region RefreshAccessTokenAsync Tests + + [Test] + public async Task RefreshAccessTokenAsync_WhenNoTokenPairExists_ShouldReturnFalse() + { + // Act + bool result = await tokenService!.RefreshAccessTokenAsync(); + + // Assert + Assert.That(result, Is.False); + Assert.That(mockMiddlewareClient!.RefreshTokenCallCount, Is.EqualTo(0)); + } + + [Test] + public async Task RefreshAccessTokenAsync_WhenNoRefreshTokenExists_ShouldReturnFalse() + { + // Arrange + TokenPair tokenPair = new() + { + AccessToken = TEST_ACCESS_TOKEN, + RefreshToken = "", + AccessTokenExpires = DateTime.UtcNow.AddHours(1), + RefreshTokenExpires = DateTime.UtcNow.AddDays(7) + }; + await tokenService!.SetTokenPair(tokenPair); + + // Act + bool result = await tokenService.RefreshAccessTokenAsync(); + + // Assert + Assert.That(result, Is.False); + Assert.That(mockMiddlewareClient!.RefreshTokenCallCount, Is.EqualTo(0)); + } + + [Test] + public async Task RefreshAccessTokenAsync_WhenTokenIsNotExpired_ShouldReturnTrueWithoutRefreshing() + { + // Arrange + string validToken = GenerateJwtToken(DateTime.UtcNow.AddHours(2)); + TokenPair tokenPair = new() + { + AccessToken = validToken, + RefreshToken = TEST_REFRESH_TOKEN, + AccessTokenExpires = DateTime.UtcNow.AddHours(2), + RefreshTokenExpires = DateTime.UtcNow.AddDays(7) + }; + await tokenService!.SetTokenPair(tokenPair); + + // Act + bool result = await tokenService.RefreshAccessTokenAsync(); + + // Assert + Assert.That(result, Is.True); + Assert.That(mockMiddlewareClient!.RefreshTokenCallCount, Is.EqualTo(0)); + } + + [Test] + public async Task RefreshAccessTokenAsync_WhenTokenIsExpiredAndRefreshSucceeds_ShouldReturnTrueAndUpdateToken() + { + // Arrange + string expiredToken = GenerateJwtToken(DateTime.UtcNow.AddHours(-1)); + TokenPair oldTokenPair = new() + { + AccessToken = expiredToken, + RefreshToken = TEST_REFRESH_TOKEN, + AccessTokenExpires = DateTime.UtcNow.AddHours(-1), + RefreshTokenExpires = DateTime.UtcNow.AddDays(7) + }; + + string newAccessToken = GenerateJwtToken(DateTime.UtcNow.AddHours(2)); + TokenPair newTokenPair = new() + { + AccessToken = newAccessToken, + RefreshToken = "new_refresh_token", + AccessTokenExpires = DateTime.UtcNow.AddHours(2), + RefreshTokenExpires = DateTime.UtcNow.AddDays(14) + }; + + await tokenService!.SetTokenPair(oldTokenPair); + mockMiddlewareClient!.NextRefreshTokenResponse = newTokenPair; + mockMiddlewareClient.ShouldRefreshSucceed = true; + + // Act + bool result = await tokenService.RefreshAccessTokenAsync(); + + // Assert + Assert.That(result, Is.True); + Assert.That(mockMiddlewareClient.RefreshTokenCallCount, Is.EqualTo(1)); + Assert.That(mockMiddlewareClient.LastRefreshRequest, Is.Not.Null); + Assert.That(mockMiddlewareClient.LastRefreshRequest!.RefreshToken, Is.EqualTo(TEST_REFRESH_TOKEN)); + + string? storedToken = await tokenService.GetAccessTokenAsync(); + Assert.That(storedToken, Is.EqualTo(newAccessToken)); + } + + [Test] + public async Task RefreshAccessTokenAsync_WhenTokenIsExpiredAndRefreshFails_ShouldReturnFalse() + { + // Arrange + string expiredToken = GenerateJwtToken(DateTime.UtcNow.AddHours(-1)); + TokenPair tokenPair = new() + { + AccessToken = expiredToken, + RefreshToken = TEST_REFRESH_TOKEN, + AccessTokenExpires = DateTime.UtcNow.AddHours(-1), + RefreshTokenExpires = DateTime.UtcNow.AddDays(7) + }; + + await tokenService!.SetTokenPair(tokenPair); + mockMiddlewareClient!.ShouldRefreshSucceed = false; + + // Act + bool result = await tokenService.RefreshAccessTokenAsync(); + + // Assert + Assert.That(result, Is.False); + Assert.That(mockMiddlewareClient.RefreshTokenCallCount, Is.EqualTo(1)); + } + + [Test] + public async Task RefreshAccessTokenAsync_WhenCalledConcurrently_ShouldOnlyRefreshOnce() + { + // Arrange + string expiredToken = GenerateJwtToken(DateTime.UtcNow.AddHours(-1)); + TokenPair tokenPair = new() + { + AccessToken = expiredToken, + RefreshToken = TEST_REFRESH_TOKEN, + AccessTokenExpires = DateTime.UtcNow.AddHours(-1), + RefreshTokenExpires = DateTime.UtcNow.AddDays(7) + }; + + string newAccessToken = GenerateJwtToken(DateTime.UtcNow.AddHours(2)); + TokenPair newTokenPair = new() + { + AccessToken = newAccessToken, + RefreshToken = "new_refresh_token", + AccessTokenExpires = DateTime.UtcNow.AddHours(2), + RefreshTokenExpires = DateTime.UtcNow.AddDays(14) + }; + + await tokenService!.SetTokenPair(tokenPair); + mockMiddlewareClient!.NextRefreshTokenResponse = newTokenPair; + mockMiddlewareClient.ShouldRefreshSucceed = true; + + // Act - call refresh token concurrently + Task task1 = tokenService.RefreshAccessTokenAsync(); + Task task2 = tokenService.RefreshAccessTokenAsync(); + Task task3 = tokenService.RefreshAccessTokenAsync(); + + await Task.WhenAll(task1, task2, task3); + + // Assert - only one refresh should have occurred due to semaphore + Assert.That(mockMiddlewareClient.RefreshTokenCallCount, Is.LessThanOrEqualTo(1)); + } + + #endregion + + #region RevokeTokens Tests + + [Test] + public async Task RevokeTokens_WhenNoTokenPairExists_ShouldNotCallMiddleware() + { + // Act + await tokenService!.RevokeTokens(); + + // Assert + Assert.That(mockMiddlewareClient!.RevokeRefreshTokenCallCount, Is.EqualTo(0)); + } + + [Test] + public async Task RevokeTokens_WhenTokenPairExists_ShouldRevokeAndClearStorage() + { + // Arrange + TokenPair tokenPair = CreateTestTokenPair(); + await tokenService!.SetTokenPair(tokenPair); + mockMiddlewareClient!.ShouldRevokeSucceed = true; + + // Act + await tokenService.RevokeTokens(); + + // Assert + Assert.That(mockMiddlewareClient.RevokeRefreshTokenCallCount, Is.EqualTo(1)); + Assert.That(mockMiddlewareClient.LastRevokeRequest, Is.Not.Null); + Assert.That(mockMiddlewareClient.LastRevokeRequest!.RefreshToken, Is.EqualTo(TEST_REFRESH_TOKEN)); + Assert.That(mockSessionStorage!.ContainsKey("token_pair"), Is.False); + } + + [Test] + public async Task RevokeTokens_AfterRevoke_ShouldClearTokenPair() + { + // Arrange + TokenPair tokenPair = CreateTestTokenPair(); + await tokenService!.SetTokenPair(tokenPair); + + // Act + await tokenService.RevokeTokens(); + + // Assert + string? accessToken = await tokenService.GetAccessTokenAsync(); + Assert.That(accessToken, Is.Null); + } + + [Test] + public async Task RevokeTokens_WhenCalledMultipleTimes_ShouldOnlyRevokeOnce() + { + // Arrange + TokenPair tokenPair = CreateTestTokenPair(); + await tokenService!.SetTokenPair(tokenPair); + + // Act + await tokenService.RevokeTokens(); + await tokenService.RevokeTokens(); + await tokenService.RevokeTokens(); + + // Assert + Assert.That(mockMiddlewareClient!.RevokeRefreshTokenCallCount, Is.EqualTo(1)); + } + + [Test] + public async Task RevokeTokens_ShouldResetInitializationState() + { + // Arrange + TokenPair tokenPair = CreateTestTokenPair(); + await tokenService!.SetTokenPair(tokenPair); + + // Verify token is accessible before revoke + string? tokenBefore = await tokenService.GetAccessTokenAsync(); + Assert.That(tokenBefore, Is.Not.Null); + + // Act + await tokenService.RevokeTokens(); + + // Set a new token pair + TokenPair newTokenPair = new() + { + AccessToken = "new.access.token", + RefreshToken = "new_refresh_token", + AccessTokenExpires = DateTime.UtcNow.AddHours(1), + RefreshTokenExpires = DateTime.UtcNow.AddDays(7) + }; + await tokenService.SetTokenPair(newTokenPair); + + // Assert - should be able to get the new token + string? tokenAfter = await tokenService.GetAccessTokenAsync(); + Assert.That(tokenAfter, Is.EqualTo("new.access.token")); + } + + #endregion + + #region Integration Tests + + [Test] + public async Task FullTokenLifecycle_SetRefreshRevoke_ShouldWorkCorrectly() + { + // Arrange + string initialToken = GenerateJwtToken(DateTime.UtcNow.AddHours(1)); + TokenPair initialTokenPair = new() + { + AccessToken = initialToken, + RefreshToken = TEST_REFRESH_TOKEN, + AccessTokenExpires = DateTime.UtcNow.AddHours(1), + RefreshTokenExpires = DateTime.UtcNow.AddDays(7) + }; + + // Act & Assert - Set + await tokenService!.SetTokenPair(initialTokenPair); + string? token1 = await tokenService.GetAccessTokenAsync(); + Assert.That(token1, Is.EqualTo(initialToken)); + + // Act & Assert - Refresh + string expiredToken = GenerateJwtToken(DateTime.UtcNow.AddHours(-1)); + TokenPair expiredTokenPair = new() + { + AccessToken = expiredToken, + RefreshToken = TEST_REFRESH_TOKEN, + AccessTokenExpires = DateTime.UtcNow.AddHours(-1), + RefreshTokenExpires = DateTime.UtcNow.AddDays(7) + }; + await tokenService.SetTokenPair(expiredTokenPair); + + string refreshedToken = GenerateJwtToken(DateTime.UtcNow.AddHours(2)); + TokenPair refreshedTokenPair = new() + { + AccessToken = refreshedToken, + RefreshToken = "new_refresh", + AccessTokenExpires = DateTime.UtcNow.AddHours(2), + RefreshTokenExpires = DateTime.UtcNow.AddDays(14) + }; + mockMiddlewareClient!.NextRefreshTokenResponse = refreshedTokenPair; + + bool refreshResult = await tokenService.RefreshAccessTokenAsync(); + Assert.That(refreshResult, Is.True); + + string? token2 = await tokenService.GetAccessTokenAsync(); + Assert.That(token2, Is.EqualTo(refreshedToken)); + + // Act & Assert - Revoke + await tokenService.RevokeTokens(); + string? token3 = await tokenService.GetAccessTokenAsync(); + Assert.That(token3, Is.Null); + Assert.That(mockMiddlewareClient.RevokeRefreshTokenCallCount, Is.EqualTo(1)); + } + + #endregion + + #region Helper Methods + + private static TokenPair CreateTestTokenPair() + { + return new TokenPair + { + AccessToken = TEST_ACCESS_TOKEN, + RefreshToken = TEST_REFRESH_TOKEN, + AccessTokenExpires = DateTime.UtcNow.AddHours(1), + RefreshTokenExpires = DateTime.UtcNow.AddDays(7) + }; + } + + private static string GenerateJwtToken(DateTime expiresAt) + { + SymmetricSecurityKey securityKey = new(Encoding.UTF8.GetBytes("ThisIsATestSecretKeyForJwtTokenGeneration123456")); + SigningCredentials credentials = new(securityKey, SecurityAlgorithms.HmacSha256); + + Claim[] claims = + [ + new Claim(JwtRegisteredClaimNames.Sub, "test_user"), + new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) + ]; + + JwtSecurityToken token = new( + issuer: "test_issuer", + audience: "test_audience", + claims: claims, + expires: expiresAt, + signingCredentials: credentials + ); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + + #endregion + } +} diff --git a/roles/ui/files/FWO.UI/Auth/AuthStateProvider.cs b/roles/ui/files/FWO.UI/Auth/AuthStateProvider.cs index 7290370cd0..ff0f3ad00b 100644 --- a/roles/ui/files/FWO.UI/Auth/AuthStateProvider.cs +++ b/roles/ui/files/FWO.UI/Auth/AuthStateProvider.cs @@ -1,4 +1,4 @@ -using FWO.Api.Client; +using FWO.Api.Client; using FWO.Api.Client.Queries; using FWO.Basics; using FWO.Config.Api; @@ -21,22 +21,37 @@ public class AuthStateProvider : AuthenticationStateProvider { private ClaimsPrincipal user = new(new ClaimsIdentity()); - public override Task GetAuthenticationStateAsync() - { - return Task.FromResult(new AuthenticationState(user)); - } + private readonly TokenService tokenService; + + public AuthStateProvider(TokenService tokenService) + { + this.tokenService = tokenService; + } + + public override async Task GetAuthenticationStateAsync() + { + return await Task.FromResult(new AuthenticationState(user)); + } - public async Task> Authenticate(string username, string password, ApiConnection apiConnection, MiddlewareClient middlewareClient, - GlobalConfig globalConfig, UserConfig userConfig, ProtectedSessionStorage sessionStorage, CircuitHandlerService circuitHandler) + public async Task> Authenticate(string username, string password, ApiConnection apiConnection, MiddlewareClient middlewareClient, + GlobalConfig globalConfig, UserConfig userConfig, CircuitHandlerService circuitHandler) { // There is no jwt in session storage. Get one from auth module. AuthenticationTokenGetParameters authenticationParameters = new() { Username = username, Password = password }; - RestResponse apiAuthResponse = await middlewareClient.AuthenticateUser(authenticationParameters); + RestResponse apiAuthResponse = await middlewareClient.AuthenticateUser(authenticationParameters); if (apiAuthResponse.StatusCode == HttpStatusCode.OK) { - string jwtString = apiAuthResponse.Data ?? throw new ArgumentException("no response data"); - await Authenticate(jwtString, apiConnection, middlewareClient, globalConfig, userConfig, circuitHandler, sessionStorage); + string tokenPairJson = apiAuthResponse.Content ?? throw new ArgumentException("no response content"); + + TokenPair tokenPair = System.Text.Json.JsonSerializer.Deserialize(tokenPairJson) ?? throw new ArgumentException("failed to deserialize token pair"); + + await tokenService.SetTokenPair(tokenPair); + + string jwtString = tokenPair.AccessToken ?? throw new ArgumentException("no access token in response"); + + await Authenticate(jwtString, apiConnection, middlewareClient, globalConfig, userConfig, circuitHandler); + Log.WriteAudit("AuthenticateUser", $"user {username} successfully authenticated"); } @@ -44,7 +59,7 @@ public async Task> Authenticate(string username, string pas } public async Task Authenticate(string jwtString, ApiConnection apiConnection, MiddlewareClient middlewareClient, - GlobalConfig globalConfig, UserConfig userConfig, CircuitHandlerService circuitHandler, ProtectedSessionStorage sessionStorage) + GlobalConfig globalConfig, UserConfig userConfig, CircuitHandlerService circuitHandler) { // Try to auth with jwt (validates it and creates user context on UI side). JwtReader jwtReader = new(jwtString); @@ -62,9 +77,6 @@ public async Task Authenticate(string jwtString, ApiConnection apiConnection, Mi { throw new AuthenticationException("not_authorized"); } - - // Save jwt in session storage. - await sessionStorage.SetAsync("jwt", jwtString); // Tell api connection to use jwt as authentication apiConnection.SetAuthHeader(jwtString); @@ -91,9 +103,6 @@ public async Task Authenticate(string jwtString, ApiConnection apiConnection, Mi userConfig.User.Ownerships = await GetAssignedOwners(userConfig.User.Jwt); circuitHandler.User = userConfig.User; - // Add jwt expiry timer - JwtEventService.AddJwtTimers(userDn, (int)jwtReader.TimeUntilExpiry().TotalMilliseconds, 1000 * 60 * globalConfig.SessionTimeoutNoticePeriod); - if (!userConfig.User.PasswordMustBeChanged) { NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(user))); @@ -101,12 +110,18 @@ public async Task Authenticate(string jwtString, ApiConnection apiConnection, Mi } else { - Deauthenticate(); + await Deauthenticate(); } } - public void Deauthenticate() + /// + /// Deauthenticate the current user and clear session storage. + /// + /// + public async Task Deauthenticate() { + await tokenService.RevokeTokens(); + user = new ClaimsPrincipal(new ClaimsIdentity()); NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(user))); } @@ -209,7 +224,7 @@ private static async Task> GetClaimList(string jwtString, string cl JwtReader jwtReader = new(jwtString); if (await jwtReader.Validate()) { - ClaimsIdentity identity = new + ClaimsIdentity identity = new ( claims: jwtReader.GetClaims(), authenticationType: "ldap", @@ -226,6 +241,6 @@ private static async Task> GetClaimList(string jwtString, string cl } return claimList; } - } + } } diff --git a/roles/ui/files/FWO.UI/Pages/Help/HelpApiLogin.cshtml b/roles/ui/files/FWO.UI/Pages/Help/HelpApiLogin.cshtml index b233cfc8e2..f1a23c830d 100644 --- a/roles/ui/files/FWO.UI/Pages/Help/HelpApiLogin.cshtml +++ b/roles/ui/files/FWO.UI/Pages/Help/HelpApiLogin.cshtml @@ -3,9 +3,9 @@ @{ Layout = "HelpLayout"; } -@section sidebar{ - @{ - await Html.RenderPartialAsync("HelpAPISidebar.cshtml"); +@section sidebar { + @{ + await Html.RenderPartialAsync("HelpAPISidebar.cshtml"); } } @using FWO.Config.Api @@ -15,21 +15,117 @@

@userConfig.GetText("login")

@(Html.Raw(userConfig.GetText("H6501")))

-

@userConfig.GetText("jwt_corr_login")

-
-
+    
+
 curl --request POST \
-    --url https://localhost:8888/api/AuthenticationToken/Get/ \
+    --url https://localhost:8888/api/AuthenticationToken/GetTokenPair/ \
     --header 'content-type: application/json' \
     --data '{"Username": "user1_demo", "Password": "cactus1"}'
-
- +
+ @userConfig.GetText("response") +
+    {
+        "accessToken": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
+        "refreshToken": "a7f3c8b9e2d1...",
+        "accessTokenExpires": "2025-12-11T14:30:00Z",
+        "refreshTokenExpires": "2026-01-10T12:00:00Z"
+    }
+    
+

@userConfig.GetText("err_incorr_login")

-
-
+    

+
 curl --request POST \
-    --url https://localhost:8888/api/AuthenticationToken/Get/ \
+    --url https://localhost:8888/api/AuthenticationToken/GetTokenPair/ \
     --header 'content-type: application/json' \
     --data '{"Username": "user1_demo", "Password": "wrong-pwd"}'
-
+
+ + @userConfig.GetText("response") + +
+        A0002 Invalid credentials
+    
+
+

@userConfig.GetText("token_refresh")

+

+
+curl --request POST \
+    --url https://localhost:8888/api/AuthenticationToken/Refresh/ \
+    --header 'content-type: application/json' \
+    --data '{"RefreshToken": "a7f3c8b9e2d1..."}'
+    
+ + @userConfig.GetText("response") + +
+    {
+        "accessToken": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
+        "refreshToken": "b8g4d9c0f3e2...",
+        "accessTokenExpires": "2025-12-11T15:30:00Z",
+        "refreshTokenExpires": "2026-01-11T13:00:00Z"
+    }
+    
+
+
@userConfig.GetText("missing_refresh_token"):
+
+
+curl --request POST \
+    --url https://localhost:8888/api/AuthenticationToken/Refresh/ \
+    --header 'content-type: application/json' \
+    --data '{}'
+    
+ + @userConfig.GetText("response") (400 Bad Request): + +
+        Refresh token is required
+    
+
+
@userConfig.GetText("invalid_refresh_token"):
+
+
+curl --request POST \
+    --url https://localhost:8888/api/AuthenticationToken/Refresh/ \
+    --header 'content-type: application/json' \
+    --data '{"RefreshToken": "invalid-or-expired-token"}'
+    
+ + @userConfig.GetText("response") (401 Unauthorized): + +
+        Invalid or expired refresh token
+    
+
+

@userConfig.GetText("token_revoke")

+

+
@userConfig.GetText("token_revoke_success"):
+
+
+curl --request POST \
+    --url https://localhost:8888/api/AuthenticationToken/Revoke/ \
+    --header 'content-type: application/json' \
+    --data '{"RefreshToken": "a7f3c8b9e2d1..."}'
+    
+ + @userConfig.GetText("response") (200 OK): + +
+        (Empty response body indicating success)
+    
+
+
@userConfig.GetText("missing_refresh_token"):
+
+curl --request POST \
+    --url https://localhost:8888/api/AuthenticationToken/Revoke/ \
+    --header 'content-type: application/json' \
+    --data '{}'
+    
+ + @userConfig.GetText("response") (400 Bad Request): + +
+        Refresh token is required
+    
+ diff --git a/roles/ui/files/FWO.UI/Pages/Login.razor b/roles/ui/files/FWO.UI/Pages/Login.razor index f29f51cac3..1969d4a94f 100644 --- a/roles/ui/files/FWO.UI/Pages/Login.razor +++ b/roles/ui/files/FWO.UI/Pages/Login.razor @@ -19,8 +19,8 @@ @inject GlobalConfig globalConfig @inject CircuitHandler circuitHandler -@if (showLoginForm) -{ +@if(showLoginForm) +{
@@ -32,8 +32,8 @@ { } -

- @*

@(userConfig.GetText("login"))

*@ +

+ @*

@(userConfig.GetText("login"))

*@
@@ -42,7 +42,7 @@
- @if (loginInProgress == false) + @if(loginInProgress == false) { @* *@ @@ -55,14 +55,14 @@ { } - +
@if(ShowWelcomeMessage) { -
+
@@ -73,10 +73,10 @@
- } + } } -@if (showPasswordChangeForm) +@if(showPasswordChangeForm) {

@(userConfig.GetText("change_password"))

@@ -91,7 +91,7 @@
- @if (passwordChangeInProgress == false) + @if(passwordChangeInProgress == false) { } @@ -137,27 +137,35 @@ //ApiConnection.Dispose(); //ApiConnection = new GraphQlApiConnection(ConfigFile.ApiServerUri); - if (firstRender) - { + if(firstRender) + { // This might be a reconnect. Check if there is a jwt in session storage. - ProtectedBrowserStorageResult jwtLoadRequest = await sessionStorage.GetAsync("jwt"); + ProtectedBrowserStorageResult jwtLoadRequest = await sessionStorage.GetAsync("token_pair"); - if (jwtLoadRequest.Success) // reconnect + if(jwtLoadRequest.Success) // reconnect { try { - await ((AuthStateProvider)AuthService).Authenticate(jwtLoadRequest.Value ?? throw new AccessViolationException("Jwt from protected storage is null"), - ApiConnection, middlewareClient, globalConfig, userConfig, ((CircuitHandlerService)circuitHandler), sessionStorage); - return; + if(jwtLoadRequest.Value?.AccessToken is not null) + { + await ((AuthStateProvider)AuthService) + .Authenticate(jwtLoadRequest.Value.AccessToken, ApiConnection, middlewareClient, globalConfig, userConfig, ((CircuitHandlerService)circuitHandler)); + + return; + } + else + { + Log.WriteError("Session Restore", "Session restore unsuccessful. Jwt from protected storage is null."); + } } - catch (Exception ex) { Log.WriteError("Session Restore", "Session restore unsuccessful.", ex); } + catch(Exception ex) { Log.WriteError("Session Restore", "Session restore unsuccessful.", ex); } } if(!string.IsNullOrWhiteSpace(globalConfig.WelcomeMessage)) { SanitizedWelcomeMessage = globalConfig.WelcomeMessage.StripDangerousHtmlTags().Replace("\n", "
"); ShowWelcomeMessage = true; - } + } // else no reconnect / reconnect unsuccessful showLoginForm = true; @@ -199,18 +207,18 @@ private async Task LoginSubmit() { - if (loginInProgress == false) + if(!loginInProgress) { loginInProgress = true; try { - RestResponse authResponse = await ((AuthStateProvider)AuthService) - .Authenticate(Username, Password, ApiConnection, middlewareClient, globalConfig, userConfig, sessionStorage, ((CircuitHandlerService)circuitHandler)); + RestResponse authResponse = await ((AuthStateProvider)AuthService) + .Authenticate(Username, Password, ApiConnection, middlewareClient, globalConfig, userConfig, ((CircuitHandlerService)circuitHandler)); - if (authResponse.StatusCode == HttpStatusCode.OK) + if(authResponse.StatusCode == HttpStatusCode.OK) { - if (userConfig.User.PasswordMustBeChanged) + if(userConfig.User.PasswordMustBeChanged) { showLoginForm = false; showPasswordChangeForm = true; @@ -220,10 +228,10 @@ else { // There was an error trying to authenticate the user. Probably invalid credentials or the middleware server is unreachable - if(authResponse.Data != null) + if(authResponse.Content != null) { // Probably invalid credentials - errorMessage = userConfig.GetApiText(authResponse.Data); + errorMessage = userConfig.GetApiText(authResponse.Content); // Visualisize the error by making border of all inputboxes red InputClass = "is-invalid"; Log.WriteInfo("Login", $"Login of user {Username} failed: " + errorMessage); @@ -239,13 +247,13 @@ StateHasChanged(); } } - // Authentication exception (raised by us) - catch (AuthenticationException e) + // Authentication exception (raised by us) + catch(AuthenticationException e) { errorMessage = userConfig.GetText(e.Message); } // Unknown exception - catch (Exception exception) + catch(Exception exception) { errorMessage = userConfig.GetText("E0012"); Log.WriteError("Login error", $"An unexpected exception was thrown during the log in process for user: \"{Username}\".", exception); @@ -257,7 +265,7 @@ private async Task focusInput() { - if (showLoginForm) + if(showLoginForm) { await usernameInput.FocusAsync(); } @@ -265,20 +273,20 @@ private async Task ChangePassword() { - if (passwordChangeInProgress == false) + if(!passwordChangeInProgress) { passwordChangeInProgress = true; try { chgPwErrorMessage = await (new PasswordChanger(middlewareClient)).ChangePassword(oldPassword, newPassword1, newPassword2, userConfig, globalConfig); - if (chgPwErrorMessage == "") + if(chgPwErrorMessage == "") { showPasswordChangeForm = false; ((AuthStateProvider)AuthService).ConfirmPasswordChanged(); Log.WriteAudit("ChangePassword", $"user {Username} successfully changed password"); } } - catch (Exception exception) + catch(Exception exception) { chgPwErrorMessage = exception.Message; } diff --git a/roles/ui/files/FWO.UI/Pages/Logout.razor b/roles/ui/files/FWO.UI/Pages/Logout.razor index 9c46b76e7d..aaeb87d292 100644 --- a/roles/ui/files/FWO.UI/Pages/Logout.razor +++ b/roles/ui/files/FWO.UI/Pages/Logout.razor @@ -20,15 +20,12 @@ { protected override async Task OnAfterRenderAsync(bool firstRender) { + await ((AuthStateProvider)AuthService).Deauthenticate(); + // Dispose unmanaged ressources apiConnection.Dispose(); middlewareClient.Dispose(); - // Clear the jwt and deauthenticate - await sessionStorage.DeleteAsync("jwt"); - ((AuthStateProvider)AuthService).Deauthenticate(); - JwtEventService.RemoveJwtTimers(userConfig.User.Dn); - // Write an audit log that the user logged out UiUser user = userConfig.User; Log.WriteAudit($"Logout", $"User \"{user.Name}\" with DN: \"{user.Dn}\" logged out."); diff --git a/roles/ui/files/FWO.UI/Pages/Settings/SettingsDefaults.razor b/roles/ui/files/FWO.UI/Pages/Settings/SettingsDefaults.razor index b211725e37..f5651d6113 100644 --- a/roles/ui/files/FWO.UI/Pages/Settings/SettingsDefaults.razor +++ b/roles/ui/files/FWO.UI/Pages/Settings/SettingsDefaults.razor @@ -175,7 +175,7 @@
+ ElementToString="@(o => userConfig.GetText(o.ToString()))" Elements="Enum.GetValues(typeof(RuleOwnershipMode)).Cast()"> @(userConfig.GetText(opt.ToString())) @@ -186,7 +186,7 @@
- @foreach (Module module in Enum.GetValues(typeof(Module))) + @foreach(Module module in Enum.GetValues(typeof(Module))) {
@@ -197,6 +197,27 @@

+
+ +
+ +
+
+
+ +
+ +
+
+ @if(!IsTokenLifetimeValid()) + { +
+
+ @(userConfig.GetText("U9036")) +
+
+ } +
@@ -211,15 +232,15 @@ } else { - + } @code { - [CascadingParameter] - Action DisplayMessageInUi { get; set; } = DefaultInit.DoNothing; + [CascadingParameter] + Action DisplayMessageInUi { get; set; } = DefaultInit.DoNothing; private ConfigData? configData; private Language selectedLanguage = new Language(); @@ -254,18 +275,35 @@ else } } + private bool IsTokenLifetimeValid() + { + if(configData == null) + { + return false; + } + + int refreshTokenLifetimeHours = configData.RefreshTokenLifetimeDays * 24; + return configData.AccessTokenLifetimeHours <= refreshTokenLifetimeHours; + } + private async Task Save() { try { if(configData != null) { + if(!IsTokenLifetimeValid()) + { + DisplayMessageInUi(null, userConfig.GetText("change_default"), userConfig.GetText("U9036"), true); + return; + } + configData.DefaultLanguage = selectedLanguage.Name; configData.AutoDiscoverStartAt = autoDiscStartDate.Date.Add(autoDiscStartTime.TimeOfDay); availableModules = []; foreach(Module module in modulesVisibleDict.Keys) { - if (modulesVisibleDict[module]) + if(modulesVisibleDict[module]) { availableModules.Add(module); } @@ -293,7 +331,7 @@ else return; } - if (!e.Success && e.Error is not null ) + if(!e.Success && e.Error is not null) { DisplayMessageInUi(e.Error.InternalException, userConfig.GetText("change_default"), e.Error.Message ?? "", true); return; diff --git a/roles/ui/files/FWO.UI/Pages/Settings/SettingsRoles.razor b/roles/ui/files/FWO.UI/Pages/Settings/SettingsRoles.razor index e59c34b42c..7a2df04a9a 100644 --- a/roles/ui/files/FWO.UI/Pages/Settings/SettingsRoles.razor +++ b/roles/ui/files/FWO.UI/Pages/Settings/SettingsRoles.razor @@ -138,6 +138,7 @@ actRole.Users.Add(user); roles[roles.FindIndex(x => x.Name == actRole.Name)] = actRole; AddMode = false; + JwtEventService.PermissionsChanged(user.Dn); Log.WriteAudit( diff --git a/roles/ui/files/FWO.UI/Program.cs b/roles/ui/files/FWO.UI/Program.cs index 90e87c8a82..92dc49680e 100644 --- a/roles/ui/files/FWO.UI/Program.cs +++ b/roles/ui/files/FWO.UI/Program.cs @@ -2,6 +2,7 @@ using FWO.Api.Client; using FWO.Config.Api; using FWO.Config.File; +using FWO.Data.Middleware; using FWO.Logging; using FWO.Middleware.Client; using FWO.Services; @@ -57,12 +58,14 @@ builder.Services.AddScoped(_ => new GraphQlApiConnection(ApiUri)); builder.Services.AddScoped(_ => new MiddlewareClient(MiddlewareUri)); +builder.Services.AddScoped(); +builder.Services.AddScoped(); // Create "anonymous" (empty) jwt -MiddlewareClient middlewareClient = new MiddlewareClient(MiddlewareUri); +MiddlewareClient middlewareClient = new(MiddlewareUri); ApiConnection apiConn = new GraphQlApiConnection(ApiUri); -RestResponse createJWTResponse = middlewareClient.CreateInitialJWT().Result; +RestResponse createJWTResponse = middlewareClient.CreateInitialJWT().Result; bool connectionEstablished = createJWTResponse.IsSuccessful; int connectionAttemptsCount = 1; while (!connectionEstablished) @@ -77,7 +80,14 @@ connectionEstablished = createJWTResponse.IsSuccessful; } -string jwt = createJWTResponse.Data ?? throw new NullReferenceException("Received empty jwt."); +if (string.IsNullOrEmpty(createJWTResponse.Content)) +{ + throw new ArgumentException("JWT response content is null or empty."); +} + +TokenPair tokenPair = System.Text.Json.JsonSerializer.Deserialize(createJWTResponse.Content) ?? throw new ArgumentException("failed to deserialize token pair"); + +string jwt = tokenPair.AccessToken ?? throw new ArgumentException("Received empty jwt."); apiConn.SetAuthHeader(jwt); // Get all non-confidential configuration settings and add to a global service (for all users) diff --git a/roles/ui/files/FWO.UI/Services/ISessionStorage.cs b/roles/ui/files/FWO.UI/Services/ISessionStorage.cs new file mode 100644 index 0000000000..2c8679e44e --- /dev/null +++ b/roles/ui/files/FWO.UI/Services/ISessionStorage.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage; + +namespace FWO.Ui.Services +{ + /// + /// Interface for session storage operations to enable testing. + /// + public interface ISessionStorage + { + /// + /// Gets a value from session storage. + /// + ValueTask> GetAsync(string key); + + /// + /// Sets a value in session storage. + /// + ValueTask SetAsync(string key, object value); + + /// + /// Deletes a value from session storage. + /// + ValueTask DeleteAsync(string key); + } +} diff --git a/roles/ui/files/FWO.UI/Services/JwtEventService.cs b/roles/ui/files/FWO.UI/Services/JwtEventService.cs index ac01859fa1..44740b87f1 100644 --- a/roles/ui/files/FWO.UI/Services/JwtEventService.cs +++ b/roles/ui/files/FWO.UI/Services/JwtEventService.cs @@ -1,4 +1,4 @@ -using FWO.Basics; +using FWO.Basics; using FWO.Config.Api; namespace FWO.Ui.Services @@ -7,58 +7,16 @@ public static class JwtEventService { public static event EventHandler? OnPermissionChanged; - public static event EventHandler? OnJwtAboutToExpire; - public static event EventHandler? OnJwtExpired; - private static readonly Dictionary jwtAboutToExpireTimers = new(); - - private static readonly Dictionary jwtExpiredTimers = new(); - public static void PermissionsChanged(string userDn) { OnPermissionChanged?.Invoke(null, userDn); } - public static void JwtAboutToExpire(string userDn) - { - OnJwtAboutToExpire?.Invoke(null, userDn); - } - public static void JwtExpired(string userDn) { OnJwtExpired?.Invoke(null, userDn); } - - public static void AddJwtTimers(string userDn, int timeUntilyExpiry, int notificationTime) - { - // Dispose old timer (if existing) - RemoveJwtTimers(userDn); - - // Create new timers - if (notificationTime > 0 && timeUntilyExpiry - notificationTime > 0) - { - jwtAboutToExpireTimers[userDn] = new Timer(_ => JwtAboutToExpire(userDn), null, timeUntilyExpiry - notificationTime, int.MaxValue); - } - if (timeUntilyExpiry > 0) - { - jwtExpiredTimers[userDn] = new Timer(_ => JwtExpired(userDn), null, timeUntilyExpiry, int.MaxValue); - } - } - - public static void RemoveJwtTimers(string userDn) - { - if (jwtAboutToExpireTimers.TryGetValue(userDn, out Timer? aboutToExpire)) - { - aboutToExpire.Dispose(); - jwtAboutToExpireTimers.Remove(userDn); - } - - if (jwtExpiredTimers.TryGetValue(userDn, out Timer? expired)) - { - expired.Dispose(); - jwtExpiredTimers.Remove(userDn); - } - } } } diff --git a/roles/ui/files/FWO.UI/Services/SessionStorageWrapper.cs b/roles/ui/files/FWO.UI/Services/SessionStorageWrapper.cs new file mode 100644 index 0000000000..1c295354ef --- /dev/null +++ b/roles/ui/files/FWO.UI/Services/SessionStorageWrapper.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage; + +namespace FWO.Ui.Services +{ + /// + /// Wrapper for ProtectedSessionStorage to implement ISessionStorage. + /// + public class SessionStorageWrapper : ISessionStorage + { + private readonly ProtectedSessionStorage protectedSessionStorage; + + public SessionStorageWrapper(ProtectedSessionStorage protectedSessionStorage) + { + this.protectedSessionStorage = protectedSessionStorage; + } + + public ValueTask> GetAsync(string key) + { + return protectedSessionStorage.GetAsync(key); + } + + public ValueTask SetAsync(string key, object value) + { + return protectedSessionStorage.SetAsync(key, value); + } + + public ValueTask DeleteAsync(string key) + { + return protectedSessionStorage.DeleteAsync(key); + } + } +} diff --git a/roles/ui/files/FWO.UI/Services/TokenService.cs b/roles/ui/files/FWO.UI/Services/TokenService.cs new file mode 100644 index 0000000000..93e5f4c790 --- /dev/null +++ b/roles/ui/files/FWO.UI/Services/TokenService.cs @@ -0,0 +1,182 @@ +using FWO.Data.Middleware; +using FWO.Logging; +using FWO.Middleware.Client; +using Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage; +using RestSharp; +using System.IdentityModel.Tokens.Jwt; + +namespace FWO.Ui.Services +{ + /// + /// Manages token pairs (access + refresh tokens) for the current user session. + /// + public class TokenService + { + private readonly MiddlewareClient middlewareClient; + private readonly ISessionStorage sessionStorage; + private TokenPair? currentTokenPair; + private readonly JwtSecurityTokenHandler jwtHandler = new(); + private readonly SemaphoreSlim refreshSemaphore = new(1, 1); + private const string TOKEN_PAIR_KEY = "token_pair"; + private bool isInitialized = false; + + /// + /// Initializes a new instance of the TokenService class. + /// + /// The middleware client for token operations. + /// The session storage for persisting tokens. + public TokenService(MiddlewareClient middlewareClient, ISessionStorage sessionStorage) + { + this.middlewareClient = middlewareClient; + this.sessionStorage = sessionStorage; + } + + /// + /// Initializes the TokenService by trying to load any existing token pair from session storage. + /// + /// + private async Task Initialize() + { + if (isInitialized) return; + + ProtectedBrowserStorageResult result = await sessionStorage.GetAsync(TOKEN_PAIR_KEY); + + if (result.Success && result.Value != null) + { + currentTokenPair = result.Value; + } + + isInitialized = true; + } + + /// + /// Sets the current token pair and stores it in session storage. + /// + /// The object to set and persist. + /// A task that represents the asynchronous operation. + public async Task SetTokenPair(TokenPair tokenPair) + { + currentTokenPair = tokenPair; + await sessionStorage.SetAsync(TOKEN_PAIR_KEY, tokenPair); + isInitialized = true; + } + + /// + /// Gets the current access token. + /// + /// The access token or null if not available. + public async Task GetAccessTokenAsync() + { + await Initialize(); + return currentTokenPair?.AccessToken; + } + + /// + /// Checks if the current access token is expired or about to expire within the next minute. + /// + /// + public async Task IsAccessTokenExpired() + { + await Initialize(); + + if (currentTokenPair is null || string.IsNullOrEmpty(currentTokenPair.AccessToken)) + { + return true; + } + + try + { + JwtSecurityToken token = jwtHandler.ReadJwtToken(currentTokenPair.AccessToken); + + return token.ValidTo <= DateTime.UtcNow.AddMinutes(1); + } + catch (Exception ex) + { + Log.WriteWarning("Token Check", $"Failed to read JWT: {ex.Message}"); + return true; + } + } + + /// + /// Refreshes the access token using the current refresh token. + /// + /// True if refresh was successful, false otherwise. + public async Task RefreshAccessTokenAsync() + { + await Initialize(); + + if (currentTokenPair is null || string.IsNullOrEmpty(currentTokenPair.RefreshToken)) + { + Log.WriteWarning("Token Refresh", "No refresh token available"); + return false; + } + + await refreshSemaphore.WaitAsync(); + + try + { + if (!await IsAccessTokenExpired()) + { + return true; + } + + RefreshTokenRequest refreshRequest = new() + { + RefreshToken = currentTokenPair.RefreshToken + }; + + RestResponse response = await middlewareClient.RefreshToken(refreshRequest); + + if (response.IsSuccessful && response.Data != null) + { + await SetTokenPair(response.Data); + + Log.WriteInfo("Token Refresh", "Successfully refreshed access token"); + + return true; + } + else + { + Log.WriteWarning("Token Refresh", $"Failed to refresh token: {response.ErrorMessage ?? response.Content}"); + + return false; + } + } + catch (Exception ex) + { + Log.WriteError("Token Refresh", "Error refreshing access token", ex); + + return false; + } + finally + { + refreshSemaphore.Release(); + } + } + + /// + /// Revokes the current refresh token and clears the stored token pair. + /// + /// + public async Task RevokeTokens() + { + await Initialize(); + + if (currentTokenPair is null) + { + return; + } + + RefreshTokenRequest revokeTokenRequest = new() + { + RefreshToken = currentTokenPair.RefreshToken + }; + + await middlewareClient.RevokeRefreshToken(revokeTokenRequest); + await sessionStorage.DeleteAsync(TOKEN_PAIR_KEY); + + currentTokenPair = null; + isInitialized = false; + } + } +} diff --git a/roles/ui/files/FWO.UI/Shared/Dropdown.razor b/roles/ui/files/FWO.UI/Shared/Dropdown.razor index 8640dec4a2..8181bfb547 100644 --- a/roles/ui/files/FWO.UI/Shared/Dropdown.razor +++ b/roles/ui/files/FWO.UI/Shared/Dropdown.razor @@ -4,7 +4,7 @@ @typeparam ElementType @inject DomEventService eventService -@inject IJSRuntime? jsRuntime +@inject IJSRuntime jsRuntime @implements IDisposable
@@ -333,7 +333,7 @@ if (jsRuntime is not null) { - jsRuntime = null; + jsRuntime = default!; JsDisposed = true; } } diff --git a/roles/ui/files/FWO.UI/Shared/MainLayout.razor b/roles/ui/files/FWO.UI/Shared/MainLayout.razor index d4198f1be2..dbbcfd0c36 100644 --- a/roles/ui/files/FWO.UI/Shared/MainLayout.razor +++ b/roles/ui/files/FWO.UI/Shared/MainLayout.razor @@ -1,4 +1,5 @@ @inherits LayoutComponentBase +@using FWO.Data.Middleware @using FWO.Middleware.Client @using FWO.Services.EventMediator @using FWO.Services.EventMediator.Events @@ -20,7 +21,7 @@ @inject AuthenticationStateProvider authenticationProvider @inject ProtectedSessionStorage sessionStorage @inject CircuitHandler circuitHandler -@inject IEventMediator EventMediator +@inject TokenService tokenService @implements IDisposable @@ -42,7 +43,7 @@