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
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:
+38
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+159
@@ -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();
|
||||
}
|
||||
}
|
||||
+154
@@ -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();
|
||||
}
|
||||
}
|
||||
+76
@@ -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);
|
||||
}
|
||||
+462
@@ -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);
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -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,
|
||||
|
||||
+54
@@ -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;
|
||||
|
||||
+57
@@ -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);
|
||||
}
|
||||
+159
@@ -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";
|
||||
}
|
||||
}
|
||||
+94
@@ -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
|
||||
|
||||
+106
@@ -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>
|
||||
Reference in New Issue
Block a user