Add DSC-based MFA: enrollment, login factor, JWS helper, tenant-scoped serving

Backend:
- m_bank_user_dsc + m_dsc_challenge tables (Liquibase 0292/0293/0294)
- DSC enrollment flow: start → JNLP → JWS helper → callback → upsert
- DSC login factor: challenge → sign → verify → claim minted TFA token
- 2FA gate now also accepts an active DSC mapping (AuthenticationApiResource
  + TwoFactorAuthenticationFilter), defensively wrapped so a missing DSC table
  doesn't 500 the auth path
- TenantAwareBasicAuthenticationFilter exempts /api/v*/dsc/helper.jar and
  strips Authorization header for that path so JWS's raw GET works without
  the user seeing a browser Basic-auth prompt
- DSC helper JAR served at /api/v*/dsc/helper.jar from a configurable
  filesystem path (default ~/.cbsnexora/dsc-helper.jar)

Java helper JAR (dsc-helper/):
- Standalone Gradle subproject, Java 8 target, fat jar with Gson shaded in
- Cross-platform token picker (Windows Cert Store / ePass2003 / eToken /
  ProxKey / Other) — matches the JNLP arg contract emitted by the backend
- Sets Fineract-Platform-TenantId on its callback POST from the JNLP args
- JNLP age check now reads cbsnexora-dsc.jnlp (current name) AND
  SignTender.jnlp (legacy) — picks the youngest, never blocks if none exist

Inline KYC upsert in updateClient (mirror of the create-flow path):
- ClientKycWritePlatformService.upsertKycInline() — creates or merges KYC
  fields, bumps status to IN_PROGRESS on real changes; resets PAN verification
  on PAN edit of a verified record
- ClientWritePlatformServiceJpaRepositoryImpl.updateClient() handles the
  kyc:{} sub-object on PUT /clients/{id}, matching createClient

User uniqueness:
- m_appuser.email + m_appuser.mobile_no UNIQUE (Liquibase 0295)
- Pre-flight assertEmailAndMobileUnique() in create/update with self-exclusion
- handleDataIntegrityIssues maps the two new constraints to clean
  field-scoped error messages (race-condition safety net)

Email OTP subject line now: "Your OTP for Login - <AppName> (dd-MM-yy-HH:mm)"
where AppName comes from fineract.email.from-name (default "CBSnexora").

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aditya
2026-05-12 11:13:19 +05:30
co-authored by Claude Opus 4.7
parent 5e3212dc54
commit 8284851f67
51 changed files with 3805 additions and 5 deletions
@@ -303,4 +303,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>