Add MFA backend: SMS/email OTP, factor management, OTP audit log

- Login 2FA via @Primary MfaFactorBackedTwoFactorServiceImpl (replaces legacy)
- LincPay SMS OTP and SMTP email OTP with auto-promote to m_user_mfa_factor
- Reusable EmailSenderService + EmailTemplateService (CBSnexora branding)
- Admin OTP audit viewer (GET /v1/otp-logs) with paging, search, masking
- Self-service MFA factor API at /v1/mfa/factors
- READ_OTP_LOG permission + OTP Log Auditor role + bankadmin direct grant
- JsonAuthenticationEntryPoint suppresses Basic-auth popup for SPA paths
- TwoFactorAuthenticationFilter grants TWOFACTOR_AUTHENTICATED when no challenge
- Soft-delete + audit columns + deletion audit + version on client tables
- CIF number generator (TTYYYYNNNNNN format)
- DocumentNotFoundExceptionMapper to silence noisy WARN logs on missing images
- Liquibase migrations 0278-0291 (MFA, OTP log, audit/soft-delete, role wiring)

Note: application.properties intentionally not committed — contains local DB
and Gmail App Password fallbacks. Set SPRING_MAIL_PASSWORD,
FINERACT_DEFAULT_TENANTDB_PWD, FINERACT_HIKARI_PASSWORD as env vars in prod.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aditya
2026-05-10 13:19:10 +05:30
co-authored by Claude Opus 4.7
parent c51a36b4e9
commit 5e3212dc54
87 changed files with 5262 additions and 65 deletions
@@ -0,0 +1,200 @@
{
"info": {
"name": "CBSnexora — Login OTP (Mobile + Email)",
"description": "Send and verify login OTPs via SMS (LincPay) and email. The `Send OTP` requests automatically capture the returned `otpId` into a collection variable so the matching `Verify OTP` request works in a single click.\n\n## Setup\n\n1. Open the collection's **Variables** tab and set:\n - `baseUrl` — e.g. `https://localhost:8443/fineract-provider/api/v1`\n - `tenantId` — e.g. `default`\n - `username` — the user whose registered mobile/email should receive the OTP\n2. Run **Send Mobile OTP** (or **Send Email OTP**), check the SMS/email for the 6-digit code, paste it into the matching **Verify** request's body, and run.\n\n## Endpoints\n\n- `POST /v1/login/otp/generate` — sends OTP via LincPay SMS\n- `POST /v1/login/otp/verify` — verifies the SMS OTP\n- `POST /v1/login/otp/email/generate` — sends OTP via SMTP email (CBSnexora-branded)\n- `POST /v1/login/otp/email/verify` — verifies the email OTP\n\nAll four endpoints are public (no auth header needed). Tenant header is still required.",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"item": [
{
"name": "Mobile OTP (LincPay SMS)",
"item": [
{
"name": "Send Mobile OTP",
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"// Capture otpId so the matching Verify request works without copy/paste",
"pm.test('Status 200', function () { pm.response.to.have.status(200); });",
"var json = pm.response.json();",
"if (json && json.otpId) {",
" pm.collectionVariables.set('mobileOtpId', json.otpId);",
" console.log('Saved mobileOtpId =', json.otpId);",
"}",
"pm.test('success flag is true', function () {",
" pm.expect(json.success).to.eql(true, json.message || 'OTP send failed');",
"});"
]
}
}
],
"request": {
"method": "POST",
"header": [
{ "key": "Content-Type", "value": "application/json" },
{ "key": "Fineract-Platform-TenantId", "value": "{{tenantId}}" }
],
"body": {
"mode": "raw",
"raw": "{\n \"username\": \"{{username}}\"\n}"
},
"url": {
"raw": "{{baseUrl}}/login/otp/generate",
"host": ["{{baseUrl}}"],
"path": ["login", "otp", "generate"]
},
"description": "Sends a 6-digit OTP via LincPay SMS to the user's registered mobile.\n\nMobile is fetched server-side from `m_appuser.mobile_no` based on the username — the UI does not need to pass it.\n\nResponse stores `otpId` and `otpReference` in `m_otp_log`. The `otpId` is auto-saved to the `mobileOtpId` collection variable for the verify step."
}
},
{
"name": "Verify Mobile OTP",
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"pm.test('Status 200', function () { pm.response.to.have.status(200); });",
"var json = pm.response.json();",
"pm.test('verified flag is true', function () {",
" pm.expect(json.verified).to.eql(true, json.message || 'verification failed');",
"});"
]
}
}
],
"request": {
"method": "POST",
"header": [
{ "key": "Content-Type", "value": "application/json" },
{ "key": "Fineract-Platform-TenantId", "value": "{{tenantId}}" }
],
"body": {
"mode": "raw",
"raw": "{\n \"otpId\": {{mobileOtpId}},\n \"otp\": \"123456\"\n}"
},
"url": {
"raw": "{{baseUrl}}/login/otp/verify",
"host": ["{{baseUrl}}"],
"path": ["login", "otp", "verify"]
},
"description": "Verifies the SMS OTP delivered by LincPay. Replace `123456` with the OTP you received on your phone before sending.\n\nThe `otpId` is auto-populated from the previous `Send Mobile OTP` request via the `mobileOtpId` collection variable.\n\nVerification is delegated to LincPay's `/verify-mobile-otp` endpoint; the audit row in `m_otp_log` is updated with the result."
}
}
]
},
{
"name": "Email OTP (SMTP)",
"item": [
{
"name": "Send Email OTP",
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"pm.test('Status 200', function () { pm.response.to.have.status(200); });",
"var json = pm.response.json();",
"if (json && json.otpId) {",
" pm.collectionVariables.set('emailOtpId', json.otpId);",
" console.log('Saved emailOtpId =', json.otpId);",
"}",
"pm.test('success flag is true', function () {",
" pm.expect(json.success).to.eql(true, json.message || 'OTP send failed');",
"});"
]
}
}
],
"request": {
"method": "POST",
"header": [
{ "key": "Content-Type", "value": "application/json" },
{ "key": "Fineract-Platform-TenantId", "value": "{{tenantId}}" }
],
"body": {
"mode": "raw",
"raw": "{\n \"username\": \"{{username}}\"\n}"
},
"url": {
"raw": "{{baseUrl}}/login/otp/email/generate",
"host": ["{{baseUrl}}"],
"path": ["login", "otp", "email", "generate"]
},
"description": "Generates a 6-digit OTP, stores its SHA-256 hash in `m_otp_log`, and emails the plaintext OTP to the user's registered email (`m_appuser.email`) using the CBSnexora-branded HTML template.\n\nThe `otpId` is auto-saved to the `emailOtpId` collection variable for the verify step.\n\n**Pre-flight**: ensure `SPRING_MAIL_HOST`, `SPRING_MAIL_USERNAME`, `SPRING_MAIL_PASSWORD` env vars are populated. If SMTP is not configured, the response will be `{\"success\": false, \"message\": \"OTP send failed\"}` and the audit row marked `FAILED`."
}
},
{
"name": "Verify Email OTP",
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"pm.test('Status 200', function () { pm.response.to.have.status(200); });",
"var json = pm.response.json();",
"pm.test('verified flag is true', function () {",
" pm.expect(json.verified).to.eql(true, json.message || 'verification failed');",
"});"
]
}
}
],
"request": {
"method": "POST",
"header": [
{ "key": "Content-Type", "value": "application/json" },
{ "key": "Fineract-Platform-TenantId", "value": "{{tenantId}}" }
],
"body": {
"mode": "raw",
"raw": "{\n \"otpId\": {{emailOtpId}},\n \"otp\": \"123456\"\n}"
},
"url": {
"raw": "{{baseUrl}}/login/otp/email/verify",
"host": ["{{baseUrl}}"],
"path": ["login", "otp", "email", "verify"]
},
"description": "Verifies the email OTP. Replace `123456` with the code from the email before sending.\n\nVerification is local: the submitted OTP is SHA-256 hashed and compared against the stored hash. On success the audit row is marked `VERIFIED`; failures increment `attempts` and lock at `max-attempts` (default 5)."
}
}
]
}
],
"variable": [
{
"key": "baseUrl",
"value": "https://localhost:8443/fineract-provider/api/v1",
"type": "string",
"description": "Fineract API base URL including the version segment (e.g. /api/v1)."
},
{
"key": "tenantId",
"value": "default",
"type": "string",
"description": "Tenant identifier sent in the `Fineract-Platform-TenantId` header."
},
{
"key": "username",
"value": "alice",
"type": "string",
"description": "Username of the AppUser whose registered mobile / email will receive the OTP."
},
{
"key": "mobileOtpId",
"value": "",
"type": "string",
"description": "Auto-populated from `Send Mobile OTP` response — used by the verify request."
},
{
"key": "emailOtpId",
"value": "",
"type": "string",
"description": "Auto-populated from `Send Email OTP` response — used by the verify request."
}
]
}