feat(users): auto-enroll admin-chosen OTP channel as MFA factor at creation
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s

Admin now picks a mandatory OTP delivery method (Email/Mobile) when creating a
user. The channel is enrolled immediately as a verified m_user_mfa_factor row,
so the existing 2FA system (MfaFactorBackedTwoFactorServiceImpl + /twofactor)
challenges the user for OTP on their very first login - reusing the
already-built login-OTP infrastructure instead of a parallel flow.

Also adds a versioned, admin-manageable user-agreement (disclaimer) API
(/v1/useragreements) with per-user acceptance tracking, checked right after
every login, plus a Liquibase cleanup migration and a shared OTP-destination
masking utility extracted from the two existing login-OTP services.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 16:16:40 +05:30
co-authored by Claude Sonnet 5
parent cf60515bf6
commit 5dc175c5e1
26 changed files with 1140 additions and 29 deletions
@@ -435,6 +435,11 @@
<include file="parts/0412_activate_outward_clearing_batch_job.xml" relativeToChangelogFile="true"/>
<include file="parts/0413_inward_item_verify_signature_permission.xml" relativeToChangelogFile="true"/>
<include file="parts/0414_inward_item_full_source_details.xml" relativeToChangelogFile="true"/>
<include file="parts/0415_add_otp_delivery_method_and_first_login_gate_to_appuser.xml" relativeToChangelogFile="true"/>
<include file="parts/0416_create_user_agreement_tables.xml" relativeToChangelogFile="true"/>
<include file="parts/0417_add_first_login_gate_config.xml" relativeToChangelogFile="true"/>
<include file="parts/0418_add_user_agreement_permissions.xml" relativeToChangelogFile="true"/>
<include file="parts/0419_revert_first_login_gate_column_and_config.xml" relativeToChangelogFile="true"/>
<!-- NOTE: the numbers below (0341-0359) re-use prefixes already used above by the chequebook/CTS
clearing catchup migrations — these come from a long-diverged branch (uate) that assigned its own
sequence independently. Liquibase tracks changesets by filename + id + author, not by the numeric
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.3.xsd">
<changeSet author="fineract" id="1">
<addColumn tableName="m_appuser">
<column name="otp_delivery_method" type="varchar(10)">
<constraints nullable="true"/>
</column>
<column name="first_login_gate_pending" type="boolean" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,106 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!--
Versioned admin-manageable "user agreement" text (bank policy/warning) that newly-created users
must accept during the first-login gate. Only one row may be active at a time - enforced in the
write service (UserAgreementWritePlatformServiceImpl), not a DB constraint, since flipping "active"
is a two-row transaction (deactivate old, activate new).
-->
<changeSet id="0416_create_m_user_agreement" author="fineract">
<preConditions onFail="MARK_RAN"><not><tableExists tableName="m_user_agreement"/></not></preConditions>
<createTable tableName="m_user_agreement">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints nullable="false" primaryKey="true"/>
</column>
<column name="version" type="INT">
<constraints nullable="false"/>
</column>
<column name="title" type="VARCHAR(200)">
<constraints nullable="false"/>
</column>
<column name="body" type="TEXT">
<constraints nullable="false"/>
</column>
<column name="is_active" type="boolean" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
<column name="created_by" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="created_on" type="datetime" defaultValueComputed="CURRENT_TIMESTAMP">
<constraints nullable="false"/>
</column>
</createTable>
<addUniqueConstraint tableName="m_user_agreement" columnNames="version"
constraintName="uq_user_agreement_version"/>
<addForeignKeyConstraint baseTableName="m_user_agreement" baseColumnNames="created_by"
constraintName="fk_user_agreement_created_by"
referencedTableName="m_appuser" referencedColumnNames="id"/>
</changeSet>
<!--
Audit-proof record of a user accepting a given version of the agreement during the first-login gate.
agreement_version is a denormalized snapshot of m_user_agreement.version at acceptance time, so the
acceptance record survives future version bumps unaffected.
-->
<changeSet id="0416_create_m_user_agreement_acceptance" author="fineract">
<preConditions onFail="MARK_RAN"><not><tableExists tableName="m_user_agreement_acceptance"/></not></preConditions>
<createTable tableName="m_user_agreement_acceptance">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints nullable="false" primaryKey="true"/>
</column>
<column name="appuser_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="agreement_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="agreement_version" type="INT">
<constraints nullable="false"/>
</column>
<column name="accepted_on" type="datetime" defaultValueComputed="CURRENT_TIMESTAMP">
<constraints nullable="false"/>
</column>
<column name="ip_address" type="VARCHAR(45)"/>
<column name="user_agent" type="VARCHAR(500)"/>
</createTable>
<addUniqueConstraint tableName="m_user_agreement_acceptance" columnNames="appuser_id, agreement_id"
constraintName="uq_user_agreement_acceptance"/>
<addForeignKeyConstraint baseTableName="m_user_agreement_acceptance" baseColumnNames="appuser_id"
constraintName="fk_user_agreement_acceptance_user"
referencedTableName="m_appuser" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_user_agreement_acceptance" baseColumnNames="agreement_id"
constraintName="fk_user_agreement_acceptance_agreement"
referencedTableName="m_user_agreement" referencedColumnNames="id"/>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.3.xsd">
<changeSet author="fineract" id="1">
<insert tableName="c_configuration">
<column name="name" value="enable-first-login-otp-and-agreement-verification"/>
<column name="value" valueNumeric="0"/>
<column name="enabled" valueBoolean="false"/>
<column name="is_trap_door" valueBoolean="false"/>
<column name="description" value="If enabled, newly created users must verify OTP via their chosen channel and accept the active user agreement on first login before access is granted. Verification also auto-enrolls that channel as ongoing MFA. Existing users are unaffected. Value unused."/>
</insert>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!--
Permissions for the admin "user agreement" management API (/v1/useragreements), used to
publish the versioned agreement text every user must accept right after logging in.
-->
<changeSet id="0418-user-agreement-create-permission" author="CBS" runOnChange="false">
<sql>
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'organisation', 'CREATE_USERAGREEMENT', 'USERAGREEMENT', 'CREATE', false
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'CREATE_USERAGREEMENT');
</sql>
</changeSet>
<changeSet id="0418-user-agreement-read-permission" author="CBS" runOnChange="false">
<sql>
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'organisation', 'READ_USERAGREEMENT', 'USERAGREEMENT', 'READ', false
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'READ_USERAGREEMENT');
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!--
Reverts 0415/0417: the first-login "gate" (its own status/complete API, pending-flag, and
dedicated toggle) was replaced by simply auto-enrolling the admin-chosen OTP channel as an
already-verified m_user_mfa_factor row at user-creation time - the existing 2FA system
(MfaFactorBackedTwoFactorServiceImpl + /twofactor + TwoFactorAuthenticationComponent) then
challenges the user for OTP on their very first login with no new code at all. Neither
column nor the config toggle are read by any code anymore.
-->
<changeSet id="0419-drop-otp-delivery-method-column" author="CBS">
<preConditions onFail="MARK_RAN">
<columnExists tableName="m_appuser" columnName="otp_delivery_method"/>
</preConditions>
<dropColumn tableName="m_appuser" columnName="otp_delivery_method"/>
</changeSet>
<changeSet id="0419-drop-first-login-gate-pending-column" author="CBS">
<preConditions onFail="MARK_RAN">
<columnExists tableName="m_appuser" columnName="first_login_gate_pending"/>
</preConditions>
<dropColumn tableName="m_appuser" columnName="first_login_gate_pending"/>
</changeSet>
<changeSet id="0419-remove-first-login-gate-config" author="CBS">
<sql>
DELETE FROM c_configuration WHERE name = 'enable-first-login-otp-and-agreement-verification';
</sql>
</changeSet>
</databaseChangeLog>