merege code feature/Backend-DSC-MFA branch

This commit is contained in:
Nitesh Yadav
2026-05-12 12:48:43 +05:30
54 changed files with 5428 additions and 9 deletions
@@ -664,8 +664,6 @@ fineract.openproject.api-token=${FINERACT_OPENPROJECT_API_TOKEN:57dd9f933f113c72
# ── DIP / MNRL Mobile Number Lookup ──────────────────────────────────────────
fineract.mobile-lookup.base-url=${FINERACT_MOBILE_LOOKUP_BASE_URL:http://localhost:8080}
# ── Login OTP (2FA) — uses LincPay credentials above ─────────────────────────
fineract.security.otp.lincpay.generate-path=${FINERACT_OTP_LINCPAY_GENERATE_PATH:/mobile/generate-otp}
fineract.security.otp.lincpay.verify-path=${FINERACT_OTP_LINCPAY_VERIFY_PATH:/verify-mobile-otp}
@@ -686,3 +684,4 @@ spring.mail.properties.mail.transport.protocol=smtp
fineract.email.from-address=${FINERACT_EMAIL_FROM_ADDRESS:no-reply@cbsnexora.com}
fineract.email.from-name=${FINERACT_EMAIL_FROM_NAME:CBSnexora}
fineract.email.support-address=${FINERACT_EMAIL_SUPPORT_ADDRESS:support@cbsnexora.com}
@@ -308,4 +308,8 @@
<include file="parts/0289_seed_read_otp_log_permission.xml" relativeToChangelogFile="true"/>
<include file="parts/0290_create_otp_log_auditor_role.xml" relativeToChangelogFile="true"/>
<include file="parts/0291_grant_read_otp_log_to_bankadmin_roles.xml" relativeToChangelogFile="true"/>
<include file="parts/0292_create_m_bank_user_dsc.xml" relativeToChangelogFile="true"/>
<include file="parts/0293_create_m_dsc_challenge.xml" relativeToChangelogFile="true"/>
<include file="parts/0294_add_tfa_token_to_dsc_challenge.xml" relativeToChangelogFile="true"/>
<include file="parts/0295_unique_email_mobile_on_m_appuser.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,168 @@
<?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
-->
<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">
<!--
Stores Digital Signature Certificate (DSC) mappings per AppUser. A user may have multiple
rows; queries filter on (is_active = true, is_deleted = false, cert_status = 'ACTIVE') to
find usable mappings.
cert serial is globally unique — one cert in the world has one serial — so it's the natural
lookup key at login-verify time.
-->
<changeSet id="0292_create_m_bank_user_dsc" author="fineract">
<preConditions onFail="MARK_RAN"><not><tableExists tableName="m_bank_user_dsc"/></not></preConditions>
<createTable tableName="m_bank_user_dsc">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints nullable="false" primaryKey="true"/>
</column>
<column name="appuser_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<!-- Parity with external 'bank_user_dsc' reference schema -->
<column name="dsc_user_id" type="VARCHAR(64)"/>
<column name="dsc_serial_no" type="VARCHAR(128)">
<constraints nullable="false" unique="true" uniqueConstraintName="uq_bank_user_dsc_serial"/>
</column>
<column name="dsc_name" type="VARCHAR(255)"/>
<column name="dsc_public_key" type="TEXT">
<constraints nullable="false"/>
</column>
<column name="dsc_expiry_date" type="DATE">
<constraints nullable="false"/>
</column>
<column name="date_of_submission" type="datetime" defaultValueComputed="CURRENT_TIMESTAMP">
<constraints nullable="false"/>
</column>
<column name="dsc_type" type="VARCHAR(32)">
<constraints nullable="false"/>
</column>
<column name="common_name" type="VARCHAR(255)"/>
<!-- Cert metadata for display + future chain validation -->
<column name="issued_to" type="VARCHAR(512)"/>
<column name="issued_by" type="VARCHAR(512)"/>
<!-- Lifecycle: ACTIVE / REVOKED / EXPIRED -->
<column name="cert_status" type="VARCHAR(16)" defaultValue="ACTIVE">
<constraints nullable="false"/>
</column>
<column name="revoked_on" type="datetime"/>
<column name="revoked_by" type="BIGINT"/>
<column name="revoke_reason" type="VARCHAR(255)"/>
<!-- Standard Fineract soft-delete + audit columns -->
<column name="is_active" type="boolean" defaultValueBoolean="true">
<constraints nullable="false"/>
</column>
<column name="is_deleted" type="boolean" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
<column name="created_by" type="BIGINT"/>
<column name="created_at" type="datetime" defaultValueComputed="CURRENT_TIMESTAMP">
<constraints nullable="false"/>
</column>
<column name="modified_by" type="BIGINT"/>
<column name="modified_on" type="datetime"/>
<column name="deleted_by" type="BIGINT"/>
<column name="deleted_at" type="datetime"/>
<column name="version" type="INT" defaultValueNumeric="0">
<constraints nullable="false"/>
</column>
</createTable>
<addForeignKeyConstraint baseTableName="m_bank_user_dsc" baseColumnNames="appuser_id"
constraintName="fk_bank_user_dsc_user"
referencedTableName="m_appuser" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_bank_user_dsc" baseColumnNames="revoked_by"
constraintName="fk_bank_user_dsc_revoked_by"
referencedTableName="m_appuser" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_bank_user_dsc" baseColumnNames="created_by"
constraintName="fk_bank_user_dsc_created_by"
referencedTableName="m_appuser" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_bank_user_dsc" baseColumnNames="modified_by"
constraintName="fk_bank_user_dsc_modified_by"
referencedTableName="m_appuser" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_bank_user_dsc" baseColumnNames="deleted_by"
constraintName="fk_bank_user_dsc_deleted_by"
referencedTableName="m_appuser" referencedColumnNames="id"/>
<!-- Common query: list active DSCs for a user -->
<createIndex tableName="m_bank_user_dsc" indexName="idx_bank_user_dsc_user_status">
<column name="appuser_id"/>
<column name="cert_status"/>
<column name="is_deleted"/>
</createIndex>
<!-- Scheduled job: flip ACTIVE → EXPIRED when notAfter passes -->
<createIndex tableName="m_bank_user_dsc" indexName="idx_bank_user_dsc_expiry">
<column name="dsc_expiry_date"/>
<column name="cert_status"/>
</createIndex>
</changeSet>
<!-- Permissions: read / create / delete (delete = soft revoke) -->
<changeSet id="0292_seed_dsc_permissions" author="fineract">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'READ_DSC'</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="special"/>
<column name="code" value="READ_DSC"/>
<column name="entity_name" value="DSC"/>
<column name="action_name" value="READ"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="special"/>
<column name="code" value="CREATE_DSC"/>
<column name="entity_name" value="DSC"/>
<column name="action_name" value="CREATE"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="special"/>
<column name="code" value="DELETE_DSC"/>
<column name="entity_name" value="DSC"/>
<column name="action_name" value="DELETE"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
</changeSet>
<!-- Self Service User gets the trio so end-users can enroll/revoke their own DSC. -->
<changeSet id="0292_grant_dsc_permissions_to_self_service_role" author="fineract">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="1">SELECT COUNT(*) FROM m_role WHERE name = 'Self Service User'</sqlCheck>
</preConditions>
<sql>
INSERT INTO m_role_permission (role_id, permission_id)
SELECT r.id, p.id
FROM m_role r, m_permission p
WHERE r.name = 'Self Service User'
AND p.code IN ('READ_DSC', 'CREATE_DSC', 'DELETE_DSC')
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp
WHERE rp.role_id = r.id AND rp.permission_id = p.id
)
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,83 @@
<?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
-->
<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">
<!--
One-shot nonce for a DSC enroll or login-2FA cycle. Rows are short-lived (~2 min TTL) and
transition PENDING → VERIFIED / FAILED / EXPIRED exactly once. Kept after terminal state for
audit; cleanup is the job of a scheduled task.
random_no is the public handle embedded in the JNLP and in the helper's callback URL —
UUID v4 length, sufficient entropy that guessing a pending nonce is infeasible.
-->
<changeSet id="0293_create_m_dsc_challenge" author="fineract">
<preConditions onFail="MARK_RAN"><not><tableExists tableName="m_dsc_challenge"/></not></preConditions>
<createTable tableName="m_dsc_challenge">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints nullable="false" primaryKey="true"/>
</column>
<column name="random_no" type="VARCHAR(64)">
<constraints nullable="false" unique="true" uniqueConstraintName="uq_dsc_challenge_random_no"/>
</column>
<column name="appuser_id" type="BIGINT"/>
<column name="purpose" type="VARCHAR(32)">
<constraints nullable="false"/>
</column>
<column name="challenge" type="VARCHAR(128)">
<constraints nullable="false"/>
</column>
<column name="status" type="VARCHAR(16)" defaultValue="PENDING">
<constraints nullable="false"/>
</column>
<column name="cert_serial_returned" type="VARCHAR(128)"/>
<column name="signature_b64" type="TEXT"/>
<column name="dsc_id" type="BIGINT"/>
<column name="error_message" type="VARCHAR(512)"/>
<column name="attempts" type="INT" defaultValueNumeric="0">
<constraints nullable="false"/>
</column>
<column name="expires_at" type="datetime">
<constraints nullable="false"/>
</column>
<column name="verified_at" type="datetime"/>
<column name="created_at" type="datetime" defaultValueComputed="CURRENT_TIMESTAMP">
<constraints nullable="false"/>
</column>
<column name="ip_address" type="VARCHAR(64)"/>
<column name="user_agent" type="VARCHAR(512)"/>
</createTable>
<addForeignKeyConstraint baseTableName="m_dsc_challenge" baseColumnNames="appuser_id"
constraintName="fk_dsc_challenge_user"
referencedTableName="m_appuser" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_dsc_challenge" baseColumnNames="dsc_id"
constraintName="fk_dsc_challenge_dsc"
referencedTableName="m_bank_user_dsc" referencedColumnNames="id"/>
<!-- "Open challenges for this user" — drives the status-poll endpoint -->
<createIndex tableName="m_dsc_challenge" indexName="idx_dsc_challenge_user_status">
<column name="appuser_id"/>
<column name="status"/>
</createIndex>
<!-- Cleanup job filter -->
<createIndex tableName="m_dsc_challenge" indexName="idx_dsc_challenge_expires_at">
<column name="expires_at"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,39 @@
<?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
-->
<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">
<!--
DSC login flow stores the minted TFA access token on the challenge row instead of returning
it to the helper. The browser then calls POST /v1/login/dsc/{rn}/claim (basic-auth required)
to retrieve the token exactly once.
This stops the helper from carrying a bearer credential back into a browser process and
keeps the token's emission scoped to a fresh authenticated request.
-->
<changeSet id="0294_add_tfa_token_to_dsc_challenge" author="fineract">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_dsc_challenge"/>
<not><columnExists tableName="m_dsc_challenge" columnName="tfa_token"/></not>
</and>
</preConditions>
<addColumn tableName="m_dsc_challenge">
<column name="tfa_token" type="VARCHAR(255)"/>
<column name="tfa_token_claimed_at" type="datetime"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,54 @@
<?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
-->
<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">
<!--
Enforce uniqueness on m_appuser.email and m_appuser.mobile_no.
Username is already unique (constraint name 'username_org' from the upstream schema).
Email is NOT NULL in the entity; mobile_no IS nullable. Both PostgreSQL and MySQL allow
multiple NULLs in a UNIQUE constraint, so a plain UNIQUE works for mobile_no — multiple
users with no mobile remain valid.
Note: if your existing data already contains duplicate emails or mobiles, this migration
WILL fail. Dedupe first:
SELECT LOWER(email), COUNT(*) FROM m_appuser GROUP BY LOWER(email) HAVING COUNT(*) > 1;
SELECT mobile_no, COUNT(*) FROM m_appuser WHERE mobile_no IS NOT NULL GROUP BY mobile_no HAVING COUNT(*) > 1;
-->
<changeSet id="0295_add_unique_email_to_m_appuser" author="fineract">
<preConditions onFail="MARK_RAN">
<not>
<indexExists tableName="m_appuser" indexName="uq_m_appuser_email"/>
</not>
</preConditions>
<addUniqueConstraint tableName="m_appuser" columnNames="email"
constraintName="uq_m_appuser_email"/>
</changeSet>
<changeSet id="0295_add_unique_mobile_no_to_m_appuser" author="fineract">
<preConditions onFail="MARK_RAN">
<not>
<indexExists tableName="m_appuser" indexName="uq_m_appuser_mobile_no"/>
</not>
</preConditions>
<!-- Drop the plain non-unique index added in 0283 first — the unique constraint replaces it. -->
<dropIndex tableName="m_appuser" indexName="idx_m_appuser_mobile_no"/>
<addUniqueConstraint tableName="m_appuser" columnNames="mobile_no"
constraintName="uq_m_appuser_mobile_no"/>
</changeSet>
</databaseChangeLog>