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:
co-authored by
Claude Opus 4.7
parent
5e3212dc54
commit
8284851f67
@@ -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>
|
||||
|
||||
+168
@@ -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>
|
||||
+83
@@ -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>
|
||||
+39
@@ -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>
|
||||
+54
@@ -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>
|
||||
Reference in New Issue
Block a user