feat(clients): dual-OTP verified contact changes with customer notification
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 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 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

Mobile number and email address changes now require OTP verification on
both the old and new destination (staff-entered) before the update is
accepted, at both client creation and edit time — closing a gap where an
unverified wrong number/email could never be corrected later. Every
mobile/email/address change also emails the customer at their pre-change
address so an unauthorized change is never silent.

- New ClientContactOtpService (+Impl) mirrors the existing login OTP
  services, reusing OtpLog with a CLIENT_CONTACT_CHANGE purpose.
- New sibling API resources for edit (/clients/{id}/contact-verification/otp)
  and create (/clients/contact-verification/otp, no clientId yet).
- ClientWritePlatformServiceJpaRepositoryImpl / AddressWritePlatformServiceImpl
  gate the actual field change on verified OTPs and fire
  ClientChangeNotificationService afterwards; the notification recipient is
  always snapshotted before any mutation so a same-request mobile+email
  change can't accidentally alert the new (unverified-by-its-real-owner)
  address instead of the old one.
- ClientDataValidator validates OTP id format only (ignoreIfNull) —
  enforcement lives solely in the write-service's diff-gated calls, since
  edit-client always PUTs the full client object regardless of what changed.
- 0420 migration seeds 8 CLIENTKYC action permissions
  (GenerateAadhaarOtp/VerifyAadhaar/VerifyPan/VerifyCin/VerifyDin/
  VerifyGstin/UploadCkyc/CompleteRekyc) that were referenced by existing
  command handlers but never seeded, causing a 404 for every user including
  Super User.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 14:36:48 +05:30
co-authored by Claude Sonnet 5
parent 5dc175c5e1
commit 14df914b9a
13 changed files with 1373 additions and 2 deletions
@@ -40,6 +40,7 @@ import org.apache.fineract.portfolio.client.domain.ClientAddress;
import org.apache.fineract.portfolio.client.domain.ClientAddressRepository;
import org.apache.fineract.portfolio.client.domain.ClientAddressRepositoryWrapper;
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
import org.apache.fineract.portfolio.client.service.ClientChangeNotificationService;
import org.springframework.stereotype.Service;
@Service
@@ -53,6 +54,7 @@ public class AddressWritePlatformServiceImpl implements AddressWritePlatformServ
private final AddressRepository addressRepository;
private final ClientAddressRepositoryWrapper clientAddressRepositoryWrapper;
private final AddressCommandFromApiJsonDeserializer fromApiJsonDeserializer;
private final ClientChangeNotificationService clientChangeNotificationService;
@Override
public CommandProcessingResult addClientAddress(final Long clientId, final Long addressTypeId, final JsonCommand command) {
@@ -170,6 +172,10 @@ public class AddressWritePlatformServiceImpl implements AddressWritePlatformServ
final Address addobj = this.addressRepository.getReferenceById(addressId);
// Snapshot before mutation — notification-only (no OTP gate for address; see class Javadoc /
// feature plan). Compared against the post-update state below to build the change message.
final String oldAddressSummary = formatAddressSummary(addobj);
if (!command.stringValueOfParameterNamed("addressLine1").isEmpty()) {
is_address_update = true;
@@ -271,6 +277,38 @@ public class AddressWritePlatformServiceImpl implements AddressWritePlatformServ
}
this.clientAddressRepository.saveAndFlush(clientAddressObj);
if (is_address_update) {
final String newAddressSummary = formatAddressSummary(addobj);
final Client client = clientAddressObj.getClient();
this.clientChangeNotificationService.notifyContactOrAddressChanged(client, ClientChangeNotificationService.FIELD_LABEL_ADDRESS,
oldAddressSummary, newAddressSummary, client.getEmailAddress());
}
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(clientAddressObj.getId()).build();
}
/**
* Human-readable, comma-joined summary of the address's free-text fields — used only for the
* change-notification email body (see {@link ClientChangeNotificationService}), not persisted.
*/
private String formatAddressSummary(final Address address) {
final StringBuilder sb = new StringBuilder();
appendIfPresent(sb, address.getAddressLine1());
appendIfPresent(sb, address.getAddressLine2());
appendIfPresent(sb, address.getAddressLine3());
appendIfPresent(sb, address.getTownVillage());
appendIfPresent(sb, address.getCity());
appendIfPresent(sb, address.getCountyDistrict());
appendIfPresent(sb, address.getPostalCode());
return sb.length() == 0 ? "(not set)" : sb.toString();
}
private void appendIfPresent(final StringBuilder sb, final String value) {
if (value != null && !value.isBlank()) {
if (sb.length() > 0) {
sb.append(", ");
}
sb.append(value);
}
}
}
@@ -0,0 +1,159 @@
/**
* 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.
*/
package org.apache.fineract.portfolio.client.contactverification.api;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.security.otp.data.OtpGenerateResponse;
import org.apache.fineract.infrastructure.security.otp.data.OtpVerifyResponse;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.client.api.ClientApiConstants;
import org.apache.fineract.portfolio.client.contactverification.service.ClientContactOtpService;
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
import org.springframework.stereotype.Component;
/**
* Staff-facing endpoints used to gate a client's mobileNo / emailAddress change on
* {@code PUT /v1/clients/{clientId}} behind dual OTP verification (old destination AND new
* destination must each be OTP-verified before the update call will accept the change — see
* {@code ClientDataValidator} and {@code ClientWritePlatformServiceJpaRepositoryImpl.updateClient()}).
*
* <p>
* OTP is entered by the staff member on the customer's behalf (no self-service customer portal
* exists in this deployment), which is why this resource requires a normal, fully-authenticated
* staff session rather than being public like the pre-login {@code /v1/login/otp/*} endpoints.
*
* <p>
* Deliberately does NOT contain the literal path segment {@code /self/} anywhere — that substring
* causes {@code TenantAwareBasicAuthenticationFilter} to reject any non-self-service (staff) caller
* with a 401, which triggers a browser Basic-Auth popup. See the class-level {@code @Path} below.
*
* <pre>
* POST /v1/clients/{clientId}/contact-verification/otp body: {"channel":"MOBILE"|"EMAIL","destination":"..."}
* POST /v1/clients/{clientId}/contact-verification/otp/verify body: {"otpId":123,"otp":"123456"}
* </pre>
*/
@Path("/v1/clients/{clientId}/contact-verification/otp")
@Component
@Tag(name = "Client Contact Verification",
description = "Dual-OTP verification of a client's old/new mobile number or email address, "
+ "required before PUT /v1/clients/{clientId} will accept a mobileNo/emailAddress change.")
@RequiredArgsConstructor
public class ClientContactVerificationApiResource {
private final PlatformSecurityContext context;
private final ClientRepositoryWrapper clientRepositoryWrapper;
private final ClientContactOtpService clientContactOtpService;
@POST
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Generate an OTP against a client's mobile number or email address",
description = "channel=MOBILE sends via LincPay to `destination`; channel=EMAIL sends a locally-generated "
+ "6-digit code to `destination` via SMTP. Call this once for the client's *current* (old) "
+ "destination and once for the *incoming* (new) destination before attempting the actual "
+ "PUT /v1/clients/{clientId} update — both otpId's are required as mobileOldOtpId/mobileNewOtpId "
+ "(or emailOldOtpId/emailNewOtpId) on that call.")
public OtpGenerateResponse generate(@PathParam("clientId") final Long clientId, final String apiRequestBodyAsJson,
@Context final HttpServletRequest httpRequest) {
this.context.authenticatedUser().validateHasUpdatePermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
this.clientRepositoryWrapper.findOneWithNotFoundDetection(clientId);
final JsonObject payload = parse(apiRequestBodyAsJson);
final String channel = stringOrNull(payload, "channel");
final String destination = stringOrNull(payload, "destination");
final String ipAddress = clientIp(httpRequest);
final String userAgent = httpRequest != null ? httpRequest.getHeader("User-Agent") : null;
if ("EMAIL".equalsIgnoreCase(channel)) {
return clientContactOtpService.generateEmailOtp(destination, ipAddress, userAgent);
}
if ("MOBILE".equalsIgnoreCase(channel)) {
return clientContactOtpService.generateMobileOtp(destination, ipAddress, userAgent);
}
throw new GeneralPlatformDomainRuleException("error.msg.client.contact.otp.channel.invalid",
"channel must be one of MOBILE, EMAIL", channel);
}
@POST
@Path("verify")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Verify a previously generated client-contact-change OTP")
public OtpVerifyResponse verify(@PathParam("clientId") final Long clientId, final String apiRequestBodyAsJson) {
this.context.authenticatedUser().validateHasUpdatePermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
this.clientRepositoryWrapper.findOneWithNotFoundDetection(clientId);
final JsonObject payload = parse(apiRequestBodyAsJson);
final Long otpId = longOrNull(payload, "otpId");
final String otp = stringOrNull(payload, "otp");
return clientContactOtpService.verify(otpId, otp);
}
// ── Helpers ──────────────────────────────────────────────────────────────
private JsonObject parse(final String body) {
if (body == null || body.isBlank()) {
return new JsonObject();
}
try {
return JsonParser.parseString(body).getAsJsonObject();
} catch (final Exception e) {
return new JsonObject();
}
}
private String stringOrNull(final JsonObject obj, final String key) {
return obj.has(key) && obj.get(key).isJsonPrimitive() ? obj.get(key).getAsString() : null;
}
private Long longOrNull(final JsonObject obj, final String key) {
try {
return obj.has(key) && obj.get(key).isJsonPrimitive() ? obj.get(key).getAsLong() : null;
} catch (final Exception e) {
return null;
}
}
private String clientIp(final HttpServletRequest req) {
if (req == null) {
return null;
}
final String fwd = req.getHeader("X-Forwarded-For");
if (fwd != null && !fwd.isBlank()) {
return fwd.split(",")[0].trim();
}
return req.getRemoteAddr();
}
}
@@ -0,0 +1,154 @@
/**
* 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.
*/
package org.apache.fineract.portfolio.client.contactverification.api;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.security.otp.data.OtpGenerateResponse;
import org.apache.fineract.infrastructure.security.otp.data.OtpVerifyResponse;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.client.api.ClientApiConstants;
import org.apache.fineract.portfolio.client.contactverification.service.ClientContactOtpService;
import org.springframework.stereotype.Component;
/**
* Sibling of {@link ClientContactVerificationApiResource}, for the CLIENT CREATION flow — a
* brand-new client has no id yet, so the {@code {clientId}} path segment used by the edit-time
* resource (which looks the client up via {@code findOneWithNotFoundDetection}) can't be reused
* as-is here. Same {@code ClientContactOtpService} underneath, same request/response shapes —
* only the path (no {@code clientId} segment, so no client-existence check) and the permission
* (CREATE instead of UPDATE) differ.
*
* <p>
* Segment count disambiguates this from {@code ClientContactVerificationApiResource}'s path at the
* JAX-RS routing level (4 segments here vs. 5 with {@code {clientId}}), so both resources coexist
* without ambiguity.
*
* <p>
* Deliberately does NOT contain the literal path segment {@code /self/} anywhere — see
* {@link ClientContactVerificationApiResource}'s class Javadoc for why that substring is unsafe in
* this codebase.
*
* <pre>
* POST /v1/clients/contact-verification/otp body: {"channel":"MOBILE"|"EMAIL","destination":"..."}
* POST /v1/clients/contact-verification/otp/verify body: {"otpId":123,"otp":"123456"}
* </pre>
*/
@Path("/v1/clients/contact-verification/otp")
@Component
@Tag(name = "Client Creation Contact Verification",
description = "OTP verification of a brand-new client's mobile number / email address at creation time, "
+ "required before POST /v1/clients will accept the value — guarantees every mobileNo/emailAddress "
+ "ever stored was reachable at the moment it was recorded.")
@RequiredArgsConstructor
public class ClientCreationContactVerificationApiResource {
private final PlatformSecurityContext context;
private final ClientContactOtpService clientContactOtpService;
@POST
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Generate an OTP against a not-yet-created client's mobile number or email address",
description = "channel=MOBILE sends via LincPay to `destination`; channel=EMAIL sends a locally-generated "
+ "6-digit code to `destination` via SMTP. The returned otpId is required as mobileNewOtpId / "
+ "emailNewOtpId on the subsequent POST /v1/clients call.")
public OtpGenerateResponse generate(final String apiRequestBodyAsJson, @Context final HttpServletRequest httpRequest) {
this.context.authenticatedUser().validateHasCreatePermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
final JsonObject payload = parse(apiRequestBodyAsJson);
final String channel = stringOrNull(payload, "channel");
final String destination = stringOrNull(payload, "destination");
final String ipAddress = clientIp(httpRequest);
final String userAgent = httpRequest != null ? httpRequest.getHeader("User-Agent") : null;
if ("EMAIL".equalsIgnoreCase(channel)) {
return clientContactOtpService.generateEmailOtp(destination, ipAddress, userAgent);
}
if ("MOBILE".equalsIgnoreCase(channel)) {
return clientContactOtpService.generateMobileOtp(destination, ipAddress, userAgent);
}
throw new GeneralPlatformDomainRuleException("error.msg.client.contact.otp.channel.invalid",
"channel must be one of MOBILE, EMAIL", channel);
}
@POST
@Path("verify")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Verify a previously generated client-creation contact OTP")
public OtpVerifyResponse verify(final String apiRequestBodyAsJson) {
this.context.authenticatedUser().validateHasCreatePermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
final JsonObject payload = parse(apiRequestBodyAsJson);
final Long otpId = longOrNull(payload, "otpId");
final String otp = stringOrNull(payload, "otp");
return clientContactOtpService.verify(otpId, otp);
}
// ── Helpers (mirrors ClientContactVerificationApiResource) ────────────────
private JsonObject parse(final String body) {
if (body == null || body.isBlank()) {
return new JsonObject();
}
try {
return JsonParser.parseString(body).getAsJsonObject();
} catch (final Exception e) {
return new JsonObject();
}
}
private String stringOrNull(final JsonObject obj, final String key) {
return obj.has(key) && obj.get(key).isJsonPrimitive() ? obj.get(key).getAsString() : null;
}
private Long longOrNull(final JsonObject obj, final String key) {
try {
return obj.has(key) && obj.get(key).isJsonPrimitive() ? obj.get(key).getAsLong() : null;
} catch (final Exception e) {
return null;
}
}
private String clientIp(final HttpServletRequest req) {
if (req == null) {
return null;
}
final String fwd = req.getHeader("X-Forwarded-For");
if (fwd != null && !fwd.isBlank()) {
return fwd.split(",")[0].trim();
}
return req.getRemoteAddr();
}
}
@@ -0,0 +1,76 @@
/**
* 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.
*/
package org.apache.fineract.portfolio.client.contactverification.service;
import org.apache.fineract.infrastructure.security.otp.data.OtpGenerateResponse;
import org.apache.fineract.infrastructure.security.otp.data.OtpVerifyResponse;
/**
* Client-facing OTP generate/verify used to gate mobileNo / emailAddress changes on
* {@code PUT /v1/clients/{clientId}} (see {@code ClientDataValidator} and
* {@code ClientWritePlatformServiceJpaRepositoryImpl.updateClient()}).
*
* <p>
* This mirrors {@code LoginOtpService} / {@code EmailLoginOtpService} (same LincPay / local-hash
* mechanics, same {@code m_otp_log} bookkeeping) but takes a raw destination directly instead of
* resolving one from an {@code AppUser} — here the "user" being verified is a client's contact
* channel, entered by a staff member on the client's behalf, not a login user.
*
* <p>
* Every row created by this service uses {@code purpose = "CLIENT_CONTACT_CHANGE"} so it is
* unambiguously distinct from the login-2FA rows in the same table.
*/
public interface ClientContactOtpService {
/**
* Send an OTP to {@code mobileNumber} via LincPay (same call shape as
* {@code LoginOtpServiceImpl.generate()}). {@code userId}/{@code username} are left null on the
* {@code m_otp_log} row since no AppUser is involved.
*/
OtpGenerateResponse generateMobileOtp(String mobileNumber, String ipAddress, String userAgent);
/**
* Generate a 6-digit OTP locally, store its SHA-256 hash, and email it to {@code emailAddress}
* (same mechanics as {@code EmailLoginOtpServiceImpl.generate()}).
*/
OtpGenerateResponse generateEmailOtp(String emailAddress, String ipAddress, String userAgent);
/**
* Verify an OTP issued by either of the above. Dispatches to the LincPay delegate or the local
* hash-compare depending on the referenced row's {@code provider}.
*/
OtpVerifyResponse verify(Long otpId, String otp);
/**
* Assert that {@code otpId} references a {@code CLIENT_CONTACT_CHANGE}, {@code VERIFIED}
* {@code m_otp_log} row whose stored mobile number equals {@code expectedMobileNumber}. Throws
* {@link org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException} on
* any mismatch. Used by {@code ClientWritePlatformServiceJpaRepositoryImpl} immediately before
* applying a mobileNo change — never trusts the client-supplied JSON, always re-derives the
* expected destination from the DB (old) or the incoming command (new).
*
* @param otpIdParamLabel the request parameter name this otpId came from (e.g. {@code mobileOldOtpId},
* {@code mobileNewOtpId}, {@code emailOldOtpId}, {@code emailNewOtpId}) — used
* only to make the error message useful.
*/
void assertVerifiedMobile(Long otpId, String expectedMobileNumber, String otpIdParamLabel);
/** Email counterpart of {@link #assertVerifiedMobile(Long, String, String)}. */
void assertVerifiedEmail(Long otpId, String expectedEmailAddress, String otpIdParamLabel);
}
@@ -0,0 +1,462 @@
/**
* 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.
*/
package org.apache.fineract.portfolio.client.contactverification.service;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.HexFormat;
import java.util.Locale;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.email.service.EmailSenderService;
import org.apache.fineract.infrastructure.email.service.EmailTemplateService;
import org.apache.fineract.infrastructure.security.otp.data.OtpGenerateResponse;
import org.apache.fineract.infrastructure.security.otp.data.OtpVerifyResponse;
import org.apache.fineract.infrastructure.security.otp.domain.OtpLog;
import org.apache.fineract.infrastructure.security.otp.domain.OtpLogRepository;
import org.apache.fineract.infrastructure.security.otp.util.OtpDestinationMasker;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.client.RestTemplate;
/**
* See {@link ClientContactOtpService}. Deliberately does NOT touch {@code LoginOtpServiceImpl} /
* {@code EmailLoginOtpServiceImpl} — this is a parallel, client-contact-change-specific variant that
* happens to talk to the same LincPay endpoints / SMTP path with a raw destination instead of an
* AppUser lookup. Small helper methods (JSON post/parse, sha256, six-digit generator) are duplicated
* rather than extracted, matching how this codebase already keeps each OTP-issuing service
* self-contained (only the genuinely cross-cutting {@link OtpDestinationMasker} was pulled out).
*/
@Service
public class ClientContactOtpServiceImpl implements ClientContactOtpService {
private static final Logger LOG = LoggerFactory.getLogger(ClientContactOtpServiceImpl.class);
public static final String PURPOSE_CLIENT_CONTACT_CHANGE = "CLIENT_CONTACT_CHANGE";
private static final String PROVIDER_LINCPAY = "LINCPAY";
private static final String PROVIDER_EMAIL = "EMAIL";
private static final String STATUS_PENDING = "PENDING";
private static final String STATUS_SENT = "SENT";
private static final String STATUS_FAILED = "FAILED";
private static final String STATUS_VERIFIED = "VERIFIED";
private static final String STATUS_EXPIRED = "EXPIRED";
private static final DateTimeFormatter SUBJECT_TIMESTAMP = DateTimeFormatter.ofPattern("dd-MM-yy-HH:mm");
@Value("${fineract.kyc.lincpay.api-key}")
private String apiKey;
@Value("${fineract.kyc.lincpay.secret}")
private String secret;
@Value("${fineract.kyc.lincpay.base-url}")
private String baseUrl;
@Value("${fineract.security.otp.lincpay.generate-path:/mobile/generate-otp}")
private String generateOtpPath;
@Value("${fineract.security.otp.lincpay.verify-path:/verify-mobile-otp}")
private String verifyOtpPath;
@Value("${fineract.security.otp.ttl-seconds:300}")
private int ttlSeconds;
@Value("${fineract.security.otp.max-attempts:5}")
private int maxAttempts;
@Value("${fineract.email.from-name:CBSnexora}")
private String appName;
private final RestTemplate restTemplate;
private final OtpLogRepository otpLogRepository;
private final EmailSenderService emailSenderService;
private final EmailTemplateService emailTemplateService;
private final SecureRandom secureRandom = new SecureRandom();
@Autowired
public ClientContactOtpServiceImpl(final OtpLogRepository otpLogRepository, final EmailSenderService emailSenderService,
final EmailTemplateService emailTemplateService) {
this.restTemplate = new RestTemplate();
this.otpLogRepository = otpLogRepository;
this.emailSenderService = emailSenderService;
this.emailTemplateService = emailTemplateService;
}
// ── Generate ─────────────────────────────────────────────────────────────
@Override
@Transactional
public OtpGenerateResponse generateMobileOtp(final String mobileNumber, final String ipAddress, final String userAgent) {
if (StringUtils.isBlank(mobileNumber)) {
return OtpGenerateResponse.builder().success(false).message("mobileNumber is required").build();
}
final OtpLog log = new OtpLog();
log.setPurpose(PURPOSE_CLIENT_CONTACT_CHANGE);
log.setProvider(PROVIDER_LINCPAY);
log.setMobileNumber(mobileNumber);
log.setStatus(STATUS_PENDING);
log.setIpAddress(ipAddress);
log.setUserAgent(userAgent);
log.setExpiresAt(OffsetDateTime.now(ZoneOffset.UTC).plusSeconds(ttlSeconds));
final JsonObject requestBody = new JsonObject();
requestBody.addProperty("apiKey", apiKey);
requestBody.addProperty("mobileNumber", mobileNumber);
requestBody.addProperty("secretKey", secret);
final JsonObject redactedRequest = new JsonObject();
redactedRequest.addProperty("apiKey", "***REDACTED***");
redactedRequest.addProperty("mobileNumber", mobileNumber);
redactedRequest.addProperty("secretKey", "***REDACTED***");
log.setRequestPayload(redactedRequest.toString());
try {
final String url = baseUrl + generateOtpPath;
final String rawResponse = post(url, requestBody.toString());
log.setResponsePayload(truncate(rawResponse, 65000));
final JsonObject response = parseJson(rawResponse);
if (isSuccess(response)) {
log.setStatus(STATUS_SENT);
log.setOtpReference(extractField(response, "reference_id", "refNumber", "ref_number", "transactionId", "txnId",
"requestId"));
final OtpLog saved = otpLogRepository.saveAndFlush(log);
LOG.info("Client contact-change mobile OTP sent. otpId={}, mobile={}", saved.getId(),
OtpDestinationMasker.maskMobile(mobileNumber));
return OtpGenerateResponse.builder().success(true).otpId(saved.getId()).otpReference(saved.getOtpReference())
.maskedMobile(OtpDestinationMasker.maskMobile(mobileNumber)).expiresInSeconds(ttlSeconds)
.message("OTP sent").build();
}
final String errorMsg = extractField(response, "message", "errorMessage", "error", "description");
log.setStatus(STATUS_FAILED);
log.setErrorMessage(errorMsg);
otpLogRepository.saveAndFlush(log);
LOG.warn("Client contact-change mobile OTP failed: {}", errorMsg);
return OtpGenerateResponse.builder().success(false).message(errorMsg != null ? errorMsg : "OTP send failed").build();
} catch (final HttpClientErrorException | HttpServerErrorException e) {
log.setStatus(STATUS_FAILED);
log.setErrorMessage("HTTP " + e.getStatusCode() + ": " + truncate(e.getResponseBodyAsString(), 900));
log.setResponsePayload(truncate(e.getResponseBodyAsString(), 65000));
otpLogRepository.saveAndFlush(log);
LOG.error("Client contact-change mobile OTP HTTP error: {} {}", e.getStatusCode(), e.getResponseBodyAsString());
return OtpGenerateResponse.builder().success(false).message("OTP send failed (HTTP " + e.getStatusCode() + ")").build();
} catch (final Exception e) {
log.setStatus(STATUS_FAILED);
log.setErrorMessage(truncate(e.getMessage(), 900));
otpLogRepository.saveAndFlush(log);
LOG.error("Client contact-change mobile OTP unexpected error", e);
return OtpGenerateResponse.builder().success(false).message("OTP send failed: " + e.getMessage()).build();
}
}
@Override
@Transactional
public OtpGenerateResponse generateEmailOtp(final String emailAddress, final String ipAddress, final String userAgent) {
if (StringUtils.isBlank(emailAddress)) {
return OtpGenerateResponse.builder().success(false).message("emailAddress is required").build();
}
final String otp = generateSixDigitOtp();
final int validityMinutes = Math.max(1, ttlSeconds / 60);
final OtpLog log = new OtpLog();
log.setPurpose(PURPOSE_CLIENT_CONTACT_CHANGE);
log.setProvider(PROVIDER_EMAIL);
log.setEmailAddress(emailAddress);
log.setOtpHash(sha256(otp));
log.setStatus(STATUS_PENDING);
log.setIpAddress(ipAddress);
log.setUserAgent(userAgent);
log.setExpiresAt(OffsetDateTime.now(ZoneOffset.UTC).plusSeconds(ttlSeconds));
final String html = emailTemplateService.renderOtp(null, otp, validityMinutes);
final String subject = "Your OTP for Contact Verification - " + appName + " (" + LocalDateTime.now().format(SUBJECT_TIMESTAMP)
+ ")";
final boolean sent = emailSenderService.sendHtml(emailAddress, subject, html);
if (sent) {
log.setStatus(STATUS_SENT);
final OtpLog saved = otpLogRepository.saveAndFlush(log);
LOG.info("Client contact-change email OTP sent. otpId={}, email={}", saved.getId(),
OtpDestinationMasker.maskEmail(emailAddress));
return OtpGenerateResponse.builder().success(true).otpId(saved.getId())
.maskedMobile(OtpDestinationMasker.maskEmail(emailAddress)).expiresInSeconds(ttlSeconds)
.message("OTP sent to the given email address").build();
}
log.setStatus(STATUS_FAILED);
log.setErrorMessage("SMTP send failed");
otpLogRepository.saveAndFlush(log);
return OtpGenerateResponse.builder().success(false).message("OTP send failed").build();
}
// ── Verify ───────────────────────────────────────────────────────────────
@Override
@Transactional
public OtpVerifyResponse verify(final Long otpId, final String otp) {
if (otpId == null || StringUtils.isBlank(otp)) {
return OtpVerifyResponse.builder().verified(false).message("otpId and otp are required").build();
}
final OtpLog row = otpLogRepository.findFirstByIdAndIsDeletedFalseOrderByIdDesc(otpId).orElse(null);
if (row == null) {
return OtpVerifyResponse.builder().verified(false).message("OTP not found").build();
}
if (!PURPOSE_CLIENT_CONTACT_CHANGE.equals(row.getPurpose())) {
return OtpVerifyResponse.builder().verified(false).message("OTP purpose mismatch").build();
}
if (STATUS_VERIFIED.equals(row.getStatus())) {
return OtpVerifyResponse.builder().verified(false).message("OTP already used").build();
}
if (row.getExpiresAt() != null && OffsetDateTime.now(ZoneOffset.UTC).isAfter(row.getExpiresAt())) {
row.setStatus(STATUS_EXPIRED);
otpLogRepository.saveAndFlush(row);
return OtpVerifyResponse.builder().verified(false).message("OTP expired").build();
}
if (row.getAttempts() != null && row.getAttempts() >= maxAttempts) {
row.setStatus(STATUS_EXPIRED);
otpLogRepository.saveAndFlush(row);
return OtpVerifyResponse.builder().verified(false).message("Max attempts exceeded").build();
}
row.setAttempts((row.getAttempts() == null ? 0 : row.getAttempts()) + 1);
if (PROVIDER_LINCPAY.equals(row.getProvider())) {
return verifyViaLincPay(row, otp);
}
return verifyViaLocalHash(row, otp);
}
private OtpVerifyResponse verifyViaLincPay(final OtpLog row, final String otp) {
row.setOtpHash(sha256(otp));
final JsonObject requestBody = new JsonObject();
requestBody.addProperty("apiKey", apiKey);
requestBody.addProperty("mobileNumber", row.getMobileNumber());
requestBody.addProperty("otp", otp);
requestBody.addProperty("secretKey", secret);
try {
final String url = baseUrl + verifyOtpPath;
final String rawResponse = post(url, requestBody.toString());
final String existing = row.getResponsePayload() == null ? "" : row.getResponsePayload();
row.setResponsePayload(truncate(existing + "\n--verify--\n" + rawResponse, 65000));
final JsonObject response = parseJson(rawResponse);
if (isSuccess(response)) {
row.setStatus(STATUS_VERIFIED);
row.setVerifiedAt(OffsetDateTime.now(ZoneOffset.UTC));
otpLogRepository.saveAndFlush(row);
LOG.info("Client contact-change mobile OTP verified. otpId={}, mobile={}", row.getId(),
OtpDestinationMasker.maskMobile(row.getMobileNumber()));
return OtpVerifyResponse.builder().verified(true).message("OTP verified").build();
}
otpLogRepository.saveAndFlush(row);
final String providerMsg = extractField(response, "message", "errorMessage", "error", "description");
final int remaining = Math.max(0, maxAttempts - row.getAttempts());
return OtpVerifyResponse.builder().verified(false).message(providerMsg != null ? providerMsg : "Invalid OTP")
.attemptsRemaining(remaining).build();
} catch (final HttpClientErrorException | HttpServerErrorException e) {
row.setErrorMessage("HTTP " + e.getStatusCode() + ": " + truncate(e.getResponseBodyAsString(), 900));
otpLogRepository.saveAndFlush(row);
LOG.error("Client contact-change verify-mobile-otp HTTP error: {} {}", e.getStatusCode(), e.getResponseBodyAsString());
return OtpVerifyResponse.builder().verified(false).message("OTP verification failed (HTTP " + e.getStatusCode() + ")")
.build();
} catch (final Exception e) {
row.setErrorMessage(truncate(e.getMessage(), 900));
otpLogRepository.saveAndFlush(row);
LOG.error("Client contact-change verify-mobile-otp unexpected error", e);
return OtpVerifyResponse.builder().verified(false).message("OTP verification failed: " + e.getMessage()).build();
}
}
private OtpVerifyResponse verifyViaLocalHash(final OtpLog row, final String otp) {
final boolean matched = row.getOtpHash() != null && row.getOtpHash().equals(sha256(otp));
if (matched) {
row.setStatus(STATUS_VERIFIED);
row.setVerifiedAt(OffsetDateTime.now(ZoneOffset.UTC));
otpLogRepository.saveAndFlush(row);
LOG.info("Client contact-change email OTP verified. otpId={}", row.getId());
return OtpVerifyResponse.builder().verified(true).message("OTP verified").build();
}
otpLogRepository.saveAndFlush(row);
final int remaining = Math.max(0, maxAttempts - row.getAttempts());
return OtpVerifyResponse.builder().verified(false).message("Invalid OTP").attemptsRemaining(remaining).build();
}
// ── Assertion helpers used by ClientWritePlatformServiceJpaRepositoryImpl ──
@Override
public void assertVerifiedMobile(final Long otpId, final String expectedMobileNumber, final String otpIdParamLabel) {
final OtpLog row = requireVerifiedRow(otpId, otpIdParamLabel);
if (!equalsIgnoringWhitespace(row.getMobileNumber(), expectedMobileNumber)) {
throw new GeneralPlatformDomainRuleException("error.msg.client.contact.otp.destination.mismatch",
"The OTP referenced by '" + otpIdParamLabel + "' was not issued for this mobile number.", otpIdParamLabel);
}
}
@Override
public void assertVerifiedEmail(final Long otpId, final String expectedEmailAddress, final String otpIdParamLabel) {
final OtpLog row = requireVerifiedRow(otpId, otpIdParamLabel);
if (!equalsIgnoreCaseAndWhitespace(row.getEmailAddress(), expectedEmailAddress)) {
throw new GeneralPlatformDomainRuleException("error.msg.client.contact.otp.destination.mismatch",
"The OTP referenced by '" + otpIdParamLabel + "' was not issued for this email address.", otpIdParamLabel);
}
}
private OtpLog requireVerifiedRow(final Long otpId, final String otpIdParamLabel) {
if (otpId == null) {
throw new GeneralPlatformDomainRuleException("error.msg.client.contact.otp.required",
"'" + otpIdParamLabel + "' is required to change this contact detail.", otpIdParamLabel);
}
final OtpLog row = otpLogRepository.findFirstByIdAndIsDeletedFalseOrderByIdDesc(otpId).orElse(null);
if (row == null) {
throw new GeneralPlatformDomainRuleException("error.msg.client.contact.otp.not.found",
"No OTP record found for '" + otpIdParamLabel + "' = " + otpId + ".", otpIdParamLabel, otpId);
}
if (!PURPOSE_CLIENT_CONTACT_CHANGE.equals(row.getPurpose())) {
throw new GeneralPlatformDomainRuleException("error.msg.client.contact.otp.purpose.mismatch",
"'" + otpIdParamLabel + "' does not reference a contact-change OTP.", otpIdParamLabel);
}
if (!STATUS_VERIFIED.equals(row.getStatus())) {
throw new GeneralPlatformDomainRuleException("error.msg.client.contact.otp.not.verified",
"'" + otpIdParamLabel + "' has not been verified yet. Complete the OTP verification step first.", otpIdParamLabel);
}
return row;
}
private boolean equalsIgnoringWhitespace(final String a, final String b) {
final String na = a == null ? null : a.trim();
final String nb = b == null ? null : b.trim();
return na != null && na.equals(nb);
}
private boolean equalsIgnoreCaseAndWhitespace(final String a, final String b) {
final String na = a == null ? null : a.trim().toLowerCase(Locale.ROOT);
final String nb = b == null ? null : b.trim().toLowerCase(Locale.ROOT);
return na != null && na.equals(nb);
}
// ── Helpers (deliberately duplicated from LoginOtpServiceImpl/EmailLoginOtpServiceImpl —
// see class Javadoc) ────────────────────────────────────────────────────
private String post(final String url, final String requestBodyJson) {
final HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("accept", "*/*");
final HttpEntity<String> entity = new HttpEntity<>(requestBodyJson, headers);
final ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
return response.getBody();
}
private JsonObject parseJson(final String raw) {
if (raw == null || raw.isBlank()) {
return new JsonObject();
}
final JsonElement parsed = JsonParser.parseString(raw);
return parsed.isJsonObject() ? parsed.getAsJsonObject() : new JsonObject();
}
private boolean isSuccess(final JsonObject response) {
for (final String key : new String[] { "status", "statusCode", "responseCode", "code" }) {
if (response.has(key)) {
final JsonElement v = response.get(key);
if (v.isJsonPrimitive()) {
final String s = v.getAsString();
if ("200".equals(s) || "0".equals(s) || "true".equalsIgnoreCase(s) || "success".equalsIgnoreCase(s)
|| "SUCCESS".equalsIgnoreCase(s)) {
return true;
}
}
}
}
if (response.has("success") && response.get("success").isJsonPrimitive()) {
return response.get("success").getAsBoolean();
}
return false;
}
private String extractField(final JsonObject obj, final String... keys) {
if (obj == null) {
return null;
}
for (final String key : keys) {
if (obj.has(key) && obj.get(key).isJsonPrimitive()) {
return obj.get(key).getAsString();
}
}
if (obj.has("data") && obj.get("data").isJsonObject()) {
final JsonObject data = obj.getAsJsonObject("data");
for (final String key : keys) {
if (data.has(key) && data.get(key).isJsonPrimitive()) {
return data.get(key).getAsString();
}
}
}
return null;
}
private String generateSixDigitOtp() {
final int n = secureRandom.nextInt(1_000_000);
return String.format("%06d", n);
}
private String sha256(final String input) {
try {
final MessageDigest md = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(md.digest(input.getBytes(StandardCharsets.UTF_8)));
} catch (final NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 not available", e);
}
}
private String truncate(final String s, final int max) {
if (s == null) {
return null;
}
return s.length() <= max ? s : s.substring(0, max);
}
}
@@ -39,7 +39,8 @@ public class ClientApiCollectionConstants extends ClientApiConstants {
dipVerifiedParamName, dipLookupLogIdParamName, dipTimestampParamName,
alternateDipVerifiedParamName, alternateDipLookupLogIdParamName, alternateDipTimestampParamName,
nameVerifiedParamName, screeningLogIdParamName,
clientNonPersonDetailsParamName, displaynameParamName, legalFormIdParamName, datatables, isStaffParamName, kycParamName));
clientNonPersonDetailsParamName, displaynameParamName, legalFormIdParamName, datatables, isStaffParamName, kycParamName,
mobileNewOtpIdParamName, emailNewOtpIdParamName));
protected static final Set<String> CLIENT_NON_PERSON_CREATE_REQUEST_DATA_PARAMETERS = new HashSet<>(
Arrays.asList(familyMembers, address, localeParamName, dateFormatParamName, incorpNumberParamName, remarksParamName,
@@ -61,7 +62,8 @@ public class ClientApiCollectionConstants extends ClientApiConstants {
dipVerifiedParamName, dipLookupLogIdParamName, dipTimestampParamName,
alternateDipVerifiedParamName, alternateDipLookupLogIdParamName, alternateDipTimestampParamName,
nameVerifiedParamName, screeningLogIdParamName,
submittedOnDateParamName, clientNonPersonDetailsParamName, displaynameParamName, legalFormIdParamName, isStaffParamName));
submittedOnDateParamName, clientNonPersonDetailsParamName, displaynameParamName, legalFormIdParamName, isStaffParamName,
mobileOldOtpIdParamName, mobileNewOtpIdParamName, emailOldOtpIdParamName, emailNewOtpIdParamName));
protected static final Set<String> CLIENT_NON_PERSON_UPDATE_REQUEST_DATA_PARAMETERS = new HashSet<>(
Arrays.asList(localeParamName, dateFormatParamName, incorpNumberParamName, remarksParamName, incorpValidityTillParamName,
@@ -166,6 +166,24 @@ public final class ClientDataValidator {
.notExceedingLengthOf(50);
}
// A mobile number/email address entered at client creation is never re-verified before this
// point — if it's a typo (wrong number, doesn't belong to this customer), the client would be
// permanently stuck later: editing it requires proving ownership of the OLD (wrong) value via
// OTP, which is impossible if that value was never real. Requiring a verified OTP for the
// value here — the same ClientContactOtpService used by the edit-time dual-OTP gate, just the
// single "new" leg since there's no "old" value yet — guarantees every mobileNo/emailAddress
// ever stored was actually reachable at the moment it was recorded.
if (this.fromApiJsonHelper.parameterExists(ClientApiConstants.mobileNoParamName, element)) {
final Long mobileNewOtpId = this.fromApiJsonHelper.extractLongNamed(ClientApiConstants.mobileNewOtpIdParamName, element);
baseDataValidator.reset().parameter(ClientApiConstants.mobileNewOtpIdParamName).value(mobileNewOtpId).notNull()
.longGreaterThanZero();
}
if (this.fromApiJsonHelper.parameterExists(ClientApiConstants.emailAddressParamName, element)) {
final Long emailNewOtpId = this.fromApiJsonHelper.extractLongNamed(ClientApiConstants.emailNewOtpIdParamName, element);
baseDataValidator.reset().parameter(ClientApiConstants.emailNewOtpIdParamName).value(emailNewOtpId).notNull()
.longGreaterThanZero();
}
if (this.fromApiJsonHelper.parameterExists(ClientApiConstants.alternateMobileNoParamName, element)) {
final String alternateMobileNo = this.fromApiJsonHelper.extractStringNamed(ClientApiConstants.alternateMobileNoParamName, element);
baseDataValidator.reset().parameter(ClientApiConstants.alternateMobileNoParamName).value(alternateMobileNo).ignoreIfNull()
@@ -495,6 +513,42 @@ public final class ClientDataValidator {
.notExceedingLengthOf(50);
}
// Dual-OTP contact-change gate: a mobileNo or emailAddress change must reference an
// already-VERIFIED m_otp_log row for the OLD destination and one for the NEW destination
// (see ClientContactOtpService + ClientWritePlatformServiceJpaRepositoryImpl.updateClient()).
//
// IMPORTANT: this validator only sees the raw request JSON, not the client's current DB row —
// it cannot tell "emailAddress is present but unchanged" (this codebase's edit-client screen
// always PUTs the full client object, changed fields or not) apart from "emailAddress is
// present and being changed". Asserting these params mandatory here would reject ordinary
// saves that don't touch contact details at all. The real gate — requiring a verified otpId
// ONLY when the value actually differs from the DB — already lives correctly in
// ClientWritePlatformServiceJpaRepositoryImpl.updateClient() (inside the
// isChangeInStringParameterNamed(...) blocks) via ClientContactOtpService.assertVerifiedMobile/
// assertVerifiedEmail, which itself throws if the otpId is null. So here we only validate
// shape (a positive long) when the ids happen to be present, and leave presence-enforcement to
// the write service, which is the only place that knows whether this is a real change.
if (this.fromApiJsonHelper.parameterExists(ClientApiConstants.mobileOldOtpIdParamName, element)) {
final Long mobileOldOtpId = this.fromApiJsonHelper.extractLongNamed(ClientApiConstants.mobileOldOtpIdParamName, element);
baseDataValidator.reset().parameter(ClientApiConstants.mobileOldOtpIdParamName).value(mobileOldOtpId).ignoreIfNull()
.longGreaterThanZero();
}
if (this.fromApiJsonHelper.parameterExists(ClientApiConstants.mobileNewOtpIdParamName, element)) {
final Long mobileNewOtpId = this.fromApiJsonHelper.extractLongNamed(ClientApiConstants.mobileNewOtpIdParamName, element);
baseDataValidator.reset().parameter(ClientApiConstants.mobileNewOtpIdParamName).value(mobileNewOtpId).ignoreIfNull()
.longGreaterThanZero();
}
if (this.fromApiJsonHelper.parameterExists(ClientApiConstants.emailOldOtpIdParamName, element)) {
final Long emailOldOtpId = this.fromApiJsonHelper.extractLongNamed(ClientApiConstants.emailOldOtpIdParamName, element);
baseDataValidator.reset().parameter(ClientApiConstants.emailOldOtpIdParamName).value(emailOldOtpId).ignoreIfNull()
.longGreaterThanZero();
}
if (this.fromApiJsonHelper.parameterExists(ClientApiConstants.emailNewOtpIdParamName, element)) {
final Long emailNewOtpId = this.fromApiJsonHelper.extractLongNamed(ClientApiConstants.emailNewOtpIdParamName, element);
baseDataValidator.reset().parameter(ClientApiConstants.emailNewOtpIdParamName).value(emailNewOtpId).ignoreIfNull()
.longGreaterThanZero();
}
final Boolean active = this.fromApiJsonHelper.extractBooleanNamed(ClientApiConstants.activeParamName, element);
if (active != null) {
atLeastOneParameterPassedForUpdate = true;
@@ -0,0 +1,57 @@
/**
* 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.
*/
package org.apache.fineract.portfolio.client.service;
import org.apache.fineract.portfolio.client.domain.Client;
/**
* Notifies a client (SMS + email) whenever their mobileNo, emailAddress, or address changes. Called
* from {@code ClientWritePlatformServiceJpaRepositoryImpl.updateClient()} (after a successful
* mobileNo/emailAddress save) and from {@code AddressWritePlatformServiceImpl.updateClientAddress()}
* (after a successful address save, notification-only — address changes are not gated by OTP).
*/
public interface ClientChangeNotificationService {
String FIELD_LABEL_MOBILE_NO = "Mobile Number";
String FIELD_LABEL_EMAIL_ADDRESS = "Email Address";
String FIELD_LABEL_ADDRESS = "Address";
/**
* @param client the client whose contact detail changed (already saved with the NEW value at
* the time this is called)
* @param fieldLabel one of {@link #FIELD_LABEL_MOBILE_NO}, {@link #FIELD_LABEL_EMAIL_ADDRESS}, or
* {@link #FIELD_LABEL_ADDRESS} — use these exact constants so the implementation's
* "notify both old and new email owner" special case is triggered correctly.
* @param oldValue the value being replaced (raw, unmasked — the implementation masks contact
* destinations for display and uses the raw value to decide where to send)
* @param newValue the value it was changed to (raw, unmasked)
* @param emailAddressBeforeUpdate the client's email address as it was BEFORE this update
* transaction touched anything — used (for the mobile/address case) as the
* notification recipient instead of {@code client.getEmailAddress()}. This
* matters when mobile and email are both changed in the same request: by the
* time this is called the in-memory client already carries the NEW email, so
* reading it directly would send the "Mobile Number Updated" alert to the new
* (unverified-by-the-real-owner-until-now) address instead of the address the
* customer actually still controls. Ignored for the
* {@link #FIELD_LABEL_EMAIL_ADDRESS} case, which already always notifies
* {@code oldValue} directly.
*/
void notifyContactOrAddressChanged(Client client, String fieldLabel, String oldValue, String newValue,
String emailAddressBeforeUpdate);
}
@@ -0,0 +1,159 @@
/**
* 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.
*/
package org.apache.fineract.portfolio.client.service;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.email.service.EmailSenderService;
import org.apache.fineract.infrastructure.email.service.EmailTemplateService;
import org.apache.fineract.infrastructure.security.otp.util.OtpDestinationMasker;
import org.apache.fineract.portfolio.client.domain.Client;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
/**
* See {@link ClientChangeNotificationService}.
*
* <p>
* Email uses {@link EmailTemplateService#renderAlert(String, String, String)} (the existing generic
* branded "alert" template — not the OTP one) sent via {@link EmailSenderService}, the same
* lightweight direct-SMTP path (configured from this app's own {@code spring.mail.*} properties)
* already used by {@code EmailLoginOtpServiceImpl} for OTP emails. Deliberately does NOT use
* {@code PlatformEmailService} / {@code GmailBackedPlatformEmailService} — that path resolves SMTP
* credentials from {@code ExternalServicesPropertiesReadPlatformService} (a DB-configured "external
* services" table used by the campaign-email pipeline), which is a separate, likely-unconfigured
* configuration surface in this deployment. See class Javadoc discussion in the feature plan.
*
* <p>
* SMS is a deliberate no-op-with-warning-log: LincPay's confirmed API surface in this codebase
* ({@code fineract.security.otp.lincpay.generate-path=/mobile/generate-otp} and
* {@code .../verify-path}) only exposes OTP generate/verify, not a plain "send this exact message"
* SMS endpoint. Routing a notification through the OTP-generate endpoint would create a real,
* verifiable OTP the client never asked to receive, which is worse than sending nothing. See
* {@link #sendSmsNotification(Client, String, String, String)}.
*/
@Service
public class ClientChangeNotificationServiceImpl implements ClientChangeNotificationService {
private static final Logger LOG = LoggerFactory.getLogger(ClientChangeNotificationServiceImpl.class);
private static final DateTimeFormatter TIMESTAMP = DateTimeFormatter.ofPattern("dd-MMM-yyyy HH:mm");
@Value("${fineract.email.from-name:CBSnexora}")
private String appName;
private final EmailSenderService emailSenderService;
private final EmailTemplateService emailTemplateService;
@Autowired
public ClientChangeNotificationServiceImpl(final EmailSenderService emailSenderService,
final EmailTemplateService emailTemplateService) {
this.emailSenderService = emailSenderService;
this.emailTemplateService = emailTemplateService;
}
@Override
public void notifyContactOrAddressChanged(final Client client, final String fieldLabel, final String oldValue,
final String newValue, final String emailAddressBeforeUpdate) {
if (client == null) {
return;
}
sendEmailNotification(client, fieldLabel, oldValue, newValue, emailAddressBeforeUpdate);
sendSmsNotification(client, fieldLabel, oldValue, newValue);
}
private void sendEmailNotification(final Client client, final String fieldLabel, final String oldValue, final String newValue,
final String emailAddressBeforeUpdate) {
final String recipientName = displayName(client);
final String alertTitle = fieldLabel + " Updated";
final String alertMessage = "Your " + fieldLabel.toLowerCase() + " on file was changed from "
+ describeForDisplay(fieldLabel, oldValue) + " to " + describeForDisplay(fieldLabel, newValue) + " on "
+ LocalDateTime.now().format(TIMESTAMP) + ". If you did not authorize this change, please contact us immediately.";
final String html = emailTemplateService.renderAlert(recipientName, alertTitle, alertMessage);
final String subject = alertTitle + " - " + appName;
if (FIELD_LABEL_EMAIL_ADDRESS.equals(fieldLabel)) {
// When the email address itself is what changed, alert only the OLD address — the new
// address already proved ownership via the dual-OTP verification step at change time, so a
// duplicate confirmation there is unnecessary; the security-relevant alert is to the
// previous owner, in case the change wasn't authorized by them.
sendIfPresent(oldValue, subject, html);
} else {
// Mobile-number or address change: always alert the email the customer had BEFORE this
// update, never `client.getEmailAddress()` — if email was also changed in the very same
// request, the in-memory client already carries the NEW address by the time this runs, and
// sending "Mobile Number Updated" there would tell nobody but the (possibly unauthorized)
// new owner. The old address is the one the real customer is guaranteed to still read.
sendIfPresent(emailAddressBeforeUpdate, subject, html);
}
}
private void sendIfPresent(final String to, final String subject, final String html) {
if (StringUtils.isBlank(to)) {
LOG.warn("Change-notification email skipped — no recipient address available.");
return;
}
emailSenderService.sendHtml(to, subject, html);
}
/**
* SMS notification — see class Javadoc. Left as a clearly-logged no-op pending a confirmed
* LincPay plain-SMS endpoint; deliberately does NOT call the OTP-generate endpoint as a
* workaround.
*/
private void sendSmsNotification(final Client client, final String fieldLabel, final String oldValue, final String newValue) {
final String mobileNo = client.getMobileNo();
if (StringUtils.isBlank(mobileNo)) {
LOG.warn("Change-notification SMS skipped — client {} has no registered mobile number.", client.getId());
return;
}
LOG.warn(
"SMS change-notification NOT sent for client {} (field='{}') — no confirmed LincPay plain-SMS endpoint exists yet "
+ "(only /mobile/generate-otp and /verify-mobile-otp are configured). Falling back to email-only "
+ "notification. See ClientChangeNotificationServiceImpl Javadoc / TODO. mobile={}",
client.getId(), fieldLabel, OtpDestinationMasker.maskMobile(mobileNo));
}
private String describeForDisplay(final String fieldLabel, final String value) {
if (StringUtils.isBlank(value)) {
return "(not set)";
}
if (FIELD_LABEL_MOBILE_NO.equals(fieldLabel)) {
return OtpDestinationMasker.maskMobile(value);
}
if (FIELD_LABEL_EMAIL_ADDRESS.equals(fieldLabel)) {
return OtpDestinationMasker.maskEmail(value);
}
return value;
}
private String displayName(final Client client) {
if (StringUtils.isNotBlank(client.getFirstname())) {
return client.getFirstname();
}
if (StringUtils.isNotBlank(client.getDisplayName())) {
return client.getDisplayName();
}
return "Customer";
}
}
@@ -63,6 +63,7 @@ import org.apache.fineract.organisation.staff.domain.StaffRepositoryWrapper;
import org.apache.fineract.portfolio.account.service.AccountNumberGenerator;
import org.apache.fineract.portfolio.address.service.AddressWritePlatformService;
import org.apache.fineract.portfolio.client.api.ClientApiConstants;
import org.apache.fineract.portfolio.client.contactverification.service.ClientContactOtpService;
import org.apache.fineract.portfolio.client.data.ClientDataValidator;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.domain.ClientEnumerations;
@@ -132,6 +133,8 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
private final org.apache.fineract.portfolio.mobilelookup.domain.MobileLookupLogRepository mobileLookupLogRepository;
private final org.apache.fineract.portfolio.sanctions.domain.SanctionsScreeningLogRepository sanctionsScreeningLogRepository;
private final CifNumberGenerator cifNumberGenerator;
private final ClientContactOtpService clientContactOtpService;
private final ClientChangeNotificationService clientChangeNotificationService;
@Transactional
@Override
@@ -416,6 +419,22 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
final String mobileNo = command.stringValueOfParameterNamed(ClientApiConstants.mobileNoParamName);
final String alternateMobileNo = command.stringValueOfParameterNamed(ClientApiConstants.alternateMobileNoParamName);
final String emailAddress = command.stringValueOfParameterNamed(ClientApiConstants.emailAddressParamName);
// A wrong/unreachable mobileNo or emailAddress entered at creation can never be fixed
// later — editing it requires a verified OTP against the OLD (wrong) value, which is
// impossible if that value was never real. Verifying here (single "new" leg — there's no
// "old" value for a brand-new client) guarantees every value ever stored was reachable at
// the moment it was recorded. See ClientDataValidator.validateForCreate() for the mandatory
// otpId presence check.
if (StringUtils.isNotBlank(mobileNo)) {
final Long mobileNewOtpId = command.longValueOfParameterNamed(ClientApiConstants.mobileNewOtpIdParamName);
this.clientContactOtpService.assertVerifiedMobile(mobileNewOtpId, mobileNo, ClientApiConstants.mobileNewOtpIdParamName);
}
if (StringUtils.isNotBlank(emailAddress)) {
final Long emailNewOtpId = command.longValueOfParameterNamed(ClientApiConstants.emailNewOtpIdParamName);
this.clientContactOtpService.assertVerifiedEmail(emailNewOtpId, emailAddress, ClientApiConstants.emailNewOtpIdParamName);
}
// Title-case names at the boundary so every persistence path (REST, internal,
// bulk import) stores "Aditya Kumar Vyas" regardless of how the caller cased the
// input. This is the single write seam for new clients — normalising here means we
@@ -724,6 +743,42 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
return element.toString().trim().length() < 4;
}
/**
* Gate a mobileNo/emailAddress change on dual OTP verification: both the OLD destination (the
* client's current DB value, re-derived here — never trusted from client-supplied JSON) and the
* NEW destination (the incoming command value) must each be backed by an already-VERIFIED
* {@code m_otp_log} row of purpose {@code CLIENT_CONTACT_CHANGE}. {@code ClientDataValidator}
* guarantees the relevant field-specific old/new otpId params are structurally present whenever
* mobileNo/emailAddress appears in the request; this is the semantic check that they actually
* prove ownership of the right destinations. Param pairs are field-specific
* ({@code mobileOldOtpId}/{@code mobileNewOtpId}, {@code emailOldOtpId}/{@code emailNewOtpId})
* rather than one generic pair, so mobileNo and emailAddress can both change in the same request
* without ambiguity.
*
* <p>
* The OLD leg is skipped when the client doesn't currently have a value for this field (e.g. a
* client onboarded without a mobile number is having one set for the first time) — there is no
* existing verified channel to prove possession of in that case.
*/
private void verifyDualOtpForContactChange(final JsonCommand command, final String changedParamName, final String oldValue,
final String newValue) {
if (ClientApiConstants.mobileNoParamName.equals(changedParamName)) {
final Long mobileOldOtpId = command.longValueOfParameterNamed(ClientApiConstants.mobileOldOtpIdParamName);
final Long mobileNewOtpId = command.longValueOfParameterNamed(ClientApiConstants.mobileNewOtpIdParamName);
if (StringUtils.isNotBlank(oldValue)) {
this.clientContactOtpService.assertVerifiedMobile(mobileOldOtpId, oldValue, ClientApiConstants.mobileOldOtpIdParamName);
}
this.clientContactOtpService.assertVerifiedMobile(mobileNewOtpId, newValue, ClientApiConstants.mobileNewOtpIdParamName);
} else if (ClientApiConstants.emailAddressParamName.equals(changedParamName)) {
final Long emailOldOtpId = command.longValueOfParameterNamed(ClientApiConstants.emailOldOtpIdParamName);
final Long emailNewOtpId = command.longValueOfParameterNamed(ClientApiConstants.emailNewOtpIdParamName);
if (StringUtils.isNotBlank(oldValue)) {
this.clientContactOtpService.assertVerifiedEmail(emailOldOtpId, oldValue, ClientApiConstants.emailOldOtpIdParamName);
}
this.clientContactOtpService.assertVerifiedEmail(emailNewOtpId, newValue, ClientApiConstants.emailNewOtpIdParamName);
}
}
@Transactional
@Override
public CommandProcessingResult updateClient(final Long clientId, final JsonCommand command) {
@@ -761,10 +816,30 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
clientForUpdate.setExternalId(externalId);
}
// Dual-OTP contact-change gate — see ClientContactOtpService / ClientDataValidator.
// Track what actually changed here so we can fire the customer notification (SMS+email)
// AFTER the row is successfully saved, further down this method.
// Snapshotted BEFORE any field below is mutated — if mobile AND email are both changed in
// this same request, the mobile-change alert must still reach this original address, not
// whatever the client's email gets updated to a few lines down. See
// ClientChangeNotificationService#notifyContactOrAddressChanged Javadoc.
final String emailAddressBeforeUpdate = clientForUpdate.getEmailAddress();
String oldMobileNoForNotification = null;
String newMobileNoForNotification = null;
boolean mobileNoChanged = false;
String oldEmailAddressForNotification = null;
String newEmailAddressForNotification = null;
boolean emailAddressChanged = false;
if (command.isChangeInStringParameterNamed(ClientApiConstants.mobileNoParamName, clientForUpdate.getMobileNo())) {
final String newValue = command.stringValueOfParameterNamed(ClientApiConstants.mobileNoParamName);
final String oldValue = clientForUpdate.getMobileNo();
verifyDualOtpForContactChange(command, ClientApiConstants.mobileNoParamName, oldValue, newValue);
changes.put(ClientApiConstants.mobileNoParamName, newValue);
clientForUpdate.setMobileNo(StringUtils.defaultIfEmpty(newValue, null));
oldMobileNoForNotification = oldValue;
newMobileNoForNotification = newValue;
mobileNoChanged = true;
}
if (command.isChangeInStringParameterNamed(ClientApiConstants.alternateMobileNoParamName, clientForUpdate.getAlternateMobileNo())) {
@@ -775,8 +850,13 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
if (command.isChangeInStringParameterNamed(ClientApiConstants.emailAddressParamName, clientForUpdate.getEmailAddress())) {
final String newValue = command.stringValueOfParameterNamed(ClientApiConstants.emailAddressParamName);
final String oldValue = clientForUpdate.getEmailAddress();
verifyDualOtpForContactChange(command, ClientApiConstants.emailAddressParamName, oldValue, newValue);
changes.put(ClientApiConstants.emailAddressParamName, newValue);
clientForUpdate.setEmailAddress(StringUtils.defaultIfEmpty(newValue, null));
oldEmailAddressForNotification = oldValue;
newEmailAddressForNotification = newValue;
emailAddressChanged = true;
}
// Title-case incoming names so an update with "ADITYA" or "aditya" still ends up
@@ -1184,6 +1264,20 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
this.clientRepository.saveAndFlush(clientForUpdate);
}
// Customer notification (SMS + email) — fired only after the row is durably saved, so we
// never notify about a change that ultimately failed to persist. Address changes are
// notified from AddressWritePlatformServiceImpl instead (no OTP gate there).
if (mobileNoChanged) {
this.clientChangeNotificationService.notifyContactOrAddressChanged(clientForUpdate,
ClientChangeNotificationService.FIELD_LABEL_MOBILE_NO, oldMobileNoForNotification, newMobileNoForNotification,
emailAddressBeforeUpdate);
}
if (emailAddressChanged) {
this.clientChangeNotificationService.notifyContactOrAddressChanged(clientForUpdate,
ClientChangeNotificationService.FIELD_LABEL_EMAIL_ADDRESS, oldEmailAddressForNotification,
newEmailAddressForNotification, emailAddressBeforeUpdate);
}
if (changes.containsKey(ClientApiConstants.legalFormIdParamName)) {
Integer legalFormValue = clientForUpdate.getLegalForm();
boolean isChangedToEntity = false;
@@ -440,6 +440,7 @@
<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"/>
<include file="parts/0420_add_missing_clientkyc_action_permissions.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,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">
<!--
0216_add_client_kyc_tables.xml seeded 5 CLIENTKYC permissions (READ/CREATE/UPDATE/VERIFY/
REJECT), but 8 more @CommandType(entity="CLIENTKYC", ...) actions were added to the Java
command handlers afterward (GenerateAadhaarOtpClientKycCommandHandler,
VerifyAadhaarClientKycCommandHandler, VerifyPanClientKycCommandHandler,
VerifyCinClientKycCommandHandler, VerifyDinClientKycCommandHandler,
VerifyGstinClientKycCommandHandler, UploadCkycClientKycCommandHandler,
CompleteRekycClientKycCommandHandler) with no matching permission ever seeded for them.
The command-processing pipeline looks up the permission row by code BEFORE checking
whether the caller has been granted it - if the row doesn't exist at all, every user
(including a Super User with ALL_FUNCTIONS) gets "Permission with Code X does not exist",
not a normal 403. This seeds the 8 missing rows so those actions become usable at all;
granting them to specific roles is a separate, ordinary Manage Roles and Permissions step.
-->
<changeSet author="CBS" id="0420-add-missing-clientkyc-action-permissions">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">
SELECT COUNT(*) FROM m_permission WHERE code = 'GENERATEAADHAROTP_CLIENTKYC'
</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="GENERATEAADHAROTP_CLIENTKYC"/>
<column name="entity_name" value="CLIENTKYC"/>
<column name="action_name" value="GENERATEAADHAROTP"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="VERIFYAADHAAR_CLIENTKYC"/>
<column name="entity_name" value="CLIENTKYC"/>
<column name="action_name" value="VERIFYAADHAAR"/>
<column name="can_maker_checker" valueBoolean="true"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="VERIFYPAN_CLIENTKYC"/>
<column name="entity_name" value="CLIENTKYC"/>
<column name="action_name" value="VERIFYPAN"/>
<column name="can_maker_checker" valueBoolean="true"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="VERIFYCIN_CLIENTKYC"/>
<column name="entity_name" value="CLIENTKYC"/>
<column name="action_name" value="VERIFYCIN"/>
<column name="can_maker_checker" valueBoolean="true"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="VERIFYDIN_CLIENTKYC"/>
<column name="entity_name" value="CLIENTKYC"/>
<column name="action_name" value="VERIFYDIN"/>
<column name="can_maker_checker" valueBoolean="true"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="VERIFYGSTIN_CLIENTKYC"/>
<column name="entity_name" value="CLIENTKYC"/>
<column name="action_name" value="VERIFYGSTIN"/>
<column name="can_maker_checker" valueBoolean="true"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="UPLOADCKYC_CLIENTKYC"/>
<column name="entity_name" value="CLIENTKYC"/>
<column name="action_name" value="UPLOADCKYC"/>
<column name="can_maker_checker" valueBoolean="true"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="COMPLETEREKYC_CLIENTKYC"/>
<column name="entity_name" value="CLIENTKYC"/>
<column name="action_name" value="COMPLETEREKYC"/>
<column name="can_maker_checker" valueBoolean="true"/>
</insert>
</changeSet>
</databaseChangeLog>