Compare commits

...
3 Commits
Author SHA1 Message Date
nitesh bc30241950 Merge remote-tracking branch 'origin/uate' into dev
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 Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (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 (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 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 Documentation build / build (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 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
2026-07-27 14:24:16 +05:30
niteshandClaude Sonnet 5 14df914b9a 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>
2026-07-26 14:36:48 +05:30
niteshandClaude Sonnet 5 5dc175c5e1 feat(users): auto-enroll admin-chosen OTP channel as MFA factor at creation
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (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 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
Admin now picks a mandatory OTP delivery method (Email/Mobile) when creating a
user. The channel is enrolled immediately as a verified m_user_mfa_factor row,
so the existing 2FA system (MfaFactorBackedTwoFactorServiceImpl + /twofactor)
challenges the user for OTP on their very first login - reusing the
already-built login-OTP infrastructure instead of a parallel flow.

Also adds a versioned, admin-manageable user-agreement (disclaimer) API
(/v1/useragreements) with per-user acceptance tracking, checked right after
every login, plus a Liquibase cleanup migration and a shared OTP-destination
masking utility extracted from the two existing login-OTP services.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 16:16:40 +05:30
38 changed files with 2513 additions and 31 deletions
@@ -640,6 +640,14 @@ public class CommandWrapperBuilder {
return this;
}
public CommandWrapperBuilder createUserAgreement() {
this.actionName = "CREATE";
this.entityName = "USERAGREEMENT";
this.entityId = null;
this.href = "/useragreements";
return this;
}
public CommandWrapperBuilder createOffice() {
this.actionName = "CREATE";
this.entityName = "OFFICE";
@@ -96,6 +96,15 @@ public class ClientApiConstants {
public static final String mobileNoParamName = "mobileNo";
public static final String alternateMobileNoParamName = "alternateMobileNo";
public static final String emailAddressParamName = "emailAddress";
// Dual-OTP contact-change verification (see ClientContactOtpService) — required only when
// mobileNo or emailAddress is being changed via PUT /clients/{id}. Reference an already
// VERIFIED m_otp_log row for the OLD destination (current DB value) and the NEW destination
// (incoming value) respectively. Field-specific (not a single generic pair) so mobileNo and
// emailAddress can both change in the same request without ambiguity.
public static final String mobileOldOtpIdParamName = "mobileOldOtpId";
public static final String mobileNewOtpIdParamName = "mobileNewOtpId";
public static final String emailOldOtpIdParamName = "emailOldOtpId";
public static final String emailNewOtpIdParamName = "emailNewOtpId";
public static final String firstnameParamName = "firstname";
public static final String middlenameParamName = "middlename";
public static final String lastnameParamName = "lastname";
@@ -0,0 +1,92 @@
/**
* 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.useradministration.agreement.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
import org.apache.fineract.useradministration.domain.AppUser;
/**
* A versioned, admin-manageable "user agreement" (bank policy/warning) that every user must accept right after
* logging in. Only one row may be {@link #active} at a time; enforced transactionally by the write service, not a
* DB constraint.
*/
@Entity
@Table(name = "m_user_agreement", uniqueConstraints = { @UniqueConstraint(columnNames = "version", name = "uq_user_agreement_version") })
@Getter
@Setter
@NoArgsConstructor
public class UserAgreement extends AbstractPersistableCustom<Long> {
@Column(name = "version", nullable = false)
private Integer version;
@Column(name = "title", length = 200, nullable = false)
private String title;
@Column(name = "body", nullable = false)
private String body;
@Column(name = "is_active", nullable = false)
private boolean active;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "created_by", nullable = false)
private AppUser createdBy;
@Column(name = "created_on", nullable = false, updatable = false)
private OffsetDateTime createdOn;
@PrePersist
private void prePersist() {
if (createdOn == null) {
createdOn = OffsetDateTime.now(ZoneOffset.UTC);
}
}
public static UserAgreement create(final Integer version, final String title, final String body, final AppUser createdBy) {
final UserAgreement agreement = new UserAgreement();
agreement.version = version;
agreement.title = title;
agreement.body = body;
agreement.createdBy = createdBy;
agreement.active = true;
return agreement;
}
public void activate() {
this.active = true;
}
public void deactivate() {
this.active = false;
}
}
@@ -0,0 +1,87 @@
/**
* 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.useradministration.agreement.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
import org.apache.fineract.useradministration.domain.AppUser;
/**
* Audit-proof record of a user accepting a given version of the {@link UserAgreement} right after logging in.
* {@link #agreementVersion} is a denormalized snapshot of {@code UserAgreement.version} at acceptance time, so the
* acceptance record survives future version bumps unaffected.
*/
@Entity
@Table(name = "m_user_agreement_acceptance", uniqueConstraints = {
@UniqueConstraint(columnNames = { "appuser_id", "agreement_id" }, name = "uq_user_agreement_acceptance") })
@Getter
@Setter
@NoArgsConstructor
public class UserAgreementAcceptance extends AbstractPersistableCustom<Long> {
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "appuser_id", nullable = false)
private AppUser appUser;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "agreement_id", nullable = false)
private UserAgreement agreement;
@Column(name = "agreement_version", nullable = false)
private Integer agreementVersion;
@Column(name = "accepted_on", nullable = false, updatable = false)
private OffsetDateTime acceptedOn;
@Column(name = "ip_address", length = 45)
private String ipAddress;
@Column(name = "user_agent", length = 500)
private String userAgent;
@PrePersist
private void prePersist() {
if (acceptedOn == null) {
acceptedOn = OffsetDateTime.now(ZoneOffset.UTC);
}
}
public static UserAgreementAcceptance create(final AppUser appUser, final UserAgreement agreement, final String ipAddress,
final String userAgent) {
final UserAgreementAcceptance acceptance = new UserAgreementAcceptance();
acceptance.appUser = appUser;
acceptance.agreement = agreement;
acceptance.agreementVersion = agreement.getVersion();
acceptance.ipAddress = ipAddress;
acceptance.userAgent = userAgent;
return acceptance;
}
}
@@ -0,0 +1,26 @@
/**
* 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.useradministration.agreement.domain;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserAgreementAcceptanceRepository extends JpaRepository<UserAgreementAcceptance, Long> {
boolean existsByAppUser_IdAndAgreement_Id(Long appUserId, Long agreementId);
}
@@ -0,0 +1,30 @@
/**
* 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.useradministration.agreement.domain;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserAgreementRepository extends JpaRepository<UserAgreement, Long> {
Optional<UserAgreement> findByActiveTrue();
List<UserAgreement> findAllByOrderByVersionDesc();
}
@@ -33,6 +33,7 @@ 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.apache.fineract.useradministration.domain.AppUser;
import org.apache.fineract.useradministration.domain.AppUserRepository;
import org.apache.fineract.useradministration.mfa.domain.MfaFactorType;
@@ -133,11 +134,11 @@ public class EmailLoginOtpServiceImpl implements EmailLoginOtpService {
if (sent) {
log.setStatus(STATUS_SENT);
final OtpLog saved = otpLogRepository.saveAndFlush(log);
LOG.info("Email login OTP sent. otpId={}, user={}, email={}", saved.getId(), username, mask(email));
LOG.info("Email login OTP sent. otpId={}, user={}, email={}", saved.getId(), username, OtpDestinationMasker.maskEmail(email));
return OtpGenerateResponse.builder()
.success(true)
.otpId(saved.getId())
.maskedMobile(mask(email)) // reusing the field; UI just shows where it was sent
.maskedMobile(OtpDestinationMasker.maskEmail(email)) // reusing the field; UI just shows where it was sent
.expiresInSeconds(ttlSeconds)
.message("OTP sent to your registered email")
.build();
@@ -240,19 +241,6 @@ public class EmailLoginOtpServiceImpl implements EmailLoginOtpService {
}
}
private String mask(final String email) {
if (email == null || !email.contains("@")) {
return "****";
}
final int at = email.indexOf('@');
final String local = email.substring(0, at);
final String domain = email.substring(at);
if (local.length() <= 2) {
return "*" + local.substring(local.length() - 1) + domain;
}
return local.charAt(0) + "***" + local.charAt(local.length() - 1) + domain;
}
private String displayName(final AppUser user) {
if (user.getFirstname() != null && !user.getFirstname().isBlank()) {
return user.getFirstname();
@@ -31,6 +31,7 @@ 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.apache.fineract.useradministration.domain.AppUser;
import org.apache.fineract.useradministration.domain.AppUserRepository;
import org.apache.fineract.useradministration.mfa.domain.MfaFactorType;
@@ -156,12 +157,12 @@ public class LoginOtpServiceImpl implements LoginOtpService {
log.setOtpReference(extractField(response, "reference_id", "refNumber", "ref_number",
"transactionId", "txnId", "requestId"));
final OtpLog saved = otpLogRepository.saveAndFlush(log);
LOG.info("LincPay login OTP sent. otpId={}, mobile={}", saved.getId(), mask(mobileNumber));
LOG.info("LincPay login OTP sent. otpId={}, mobile={}", saved.getId(), OtpDestinationMasker.maskMobile(mobileNumber));
return OtpGenerateResponse.builder()
.success(true)
.otpId(saved.getId())
.otpReference(saved.getOtpReference())
.maskedMobile(mask(mobileNumber))
.maskedMobile(OtpDestinationMasker.maskMobile(mobileNumber))
.expiresInSeconds(ttlSeconds)
.message("OTP sent")
.build();
@@ -239,7 +240,7 @@ public class LoginOtpServiceImpl implements LoginOtpService {
row.setVerifiedAt(OffsetDateTime.now(ZoneOffset.UTC));
otpLogRepository.saveAndFlush(row);
autoPromoteFactor(row);
LOG.info("LincPay login OTP verified. otpId={}, mobile={}", row.getId(), mask(row.getMobileNumber()));
LOG.info("LincPay login OTP verified. otpId={}, mobile={}", row.getId(), OtpDestinationMasker.maskMobile(row.getMobileNumber()));
return OtpVerifyResponse.builder().verified(true).message("OTP verified").build();
}
@@ -364,13 +365,6 @@ public class LoginOtpServiceImpl implements LoginOtpService {
}
}
private String mask(final String mobile) {
if (mobile == null || mobile.length() < 4) {
return "****";
}
return "******" + mobile.substring(mobile.length() - 4);
}
private String truncate(final String s, final int max) {
if (s == null) {
return null;
@@ -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;
@@ -0,0 +1,162 @@
/**
* 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.useradministration.agreement.api;
import com.google.gson.JsonObject;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
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 java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.agreement.data.UserAgreementData;
import org.apache.fineract.useradministration.agreement.service.UserAgreementReadPlatformService;
import org.apache.fineract.useradministration.agreement.service.UserAgreementWritePlatformService;
import org.springframework.stereotype.Component;
/**
* Admin API for publishing/reading the versioned "user agreement" text (bank policy/warning) shown to every user
* right after they finish logging in (OTP included, if their account has it enrolled). {@code retrieveAll()},
* {@code retrieveActive()} and {@code create()} are admin-only (gated by USERAGREEMENT permissions) for managing the
* text itself. {@code status()} and {@code accept()} are for the ordinary end-user post-login flow - any
* authenticated user may call them for their own acceptance record, no special permission required.
*/
@Path("/v1/useragreements")
@Component
@Tag(name = "User Agreements", description = "Publish/read versioned user-agreement text (admin) and check/record the current user's own acceptance of it (post-login).")
@RequiredArgsConstructor
public class UserAgreementApiResource {
private static final String RESOURCE_NAME_FOR_PERMISSIONS = "USERAGREEMENT";
private final PlatformSecurityContext context;
private final UserAgreementReadPlatformService readPlatformService;
private final UserAgreementWritePlatformService writePlatformService;
private final DefaultToApiJsonSerializer<UserAgreementData> toApiJsonSerializer;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List all User Agreement versions", description = "Newest version first.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "OK", content = @Content(array = @ArraySchema(schema = @Schema(implementation = UserAgreementData.class)))) })
public String retrieveAll() {
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
final List<UserAgreementData> all = this.readPlatformService.retrieveAll();
return this.toApiJsonSerializer.serialize(all);
}
@GET
@Path("active")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve the currently active User Agreement", description = "The version every user must accept after logging in.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = UserAgreementData.class))) })
public String retrieveActive() {
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
final UserAgreementData active = this.readPlatformService.retrieveActive();
return this.toApiJsonSerializer.serialize(active);
}
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Publish a new User Agreement version", description = "Transactionally deactivates the previously-active version "
+ "and activates the new one; version number is server-assigned (currentMax + 1). Mandatory fields: title, body.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = CommandProcessingResult.class))) })
public String create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
.createUserAgreement() //
.withJson(apiRequestBodyAsJson) //
.build();
final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
return this.toApiJsonSerializer.serialize(result);
}
@GET
@Path("active/status")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Check whether the current user has accepted the active agreement", description = "Called right after "
+ "login (OTP included, if the account has it enrolled) to decide whether to show the agreement dialog. No special "
+ "permission required beyond being logged in - reflects only the caller's own acceptance record. Includes the agreement "
+ "text when not yet accepted, so the client doesn't need a second round-trip.")
public String status() {
final boolean accepted = this.writePlatformService.hasCurrentUserAcceptedActive();
final JsonObject json = new JsonObject();
json.addProperty("accepted", accepted);
if (!accepted) {
final UserAgreementData active = this.readPlatformService.retrieveActive();
json.addProperty("id", active.getId());
json.addProperty("version", active.getVersion());
json.addProperty("title", active.getTitle());
json.addProperty("body", active.getBody());
}
return json.toString();
}
@POST
@Path("active/accept")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Accept the currently active agreement", description = "Records the caller's own acceptance. Idempotent - "
+ "calling it again after already accepting is a no-op.")
public String accept(@Context final HttpServletRequest httpRequest) {
final String ipAddress = clientIp(httpRequest);
final String userAgent = httpRequest != null ? httpRequest.getHeader("User-Agent") : null;
this.writePlatformService.acceptActive(ipAddress, userAgent);
final JsonObject json = new JsonObject();
json.addProperty("accepted", true);
return json.toString();
}
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,41 @@
/**
* 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.useradministration.agreement.data;
import java.io.Serializable;
import java.time.OffsetDateTime;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
@ToString
@AllArgsConstructor
@Getter
@Setter
public class UserAgreementData implements Serializable {
private Long id;
private Integer version;
private String title;
private String body;
private boolean active;
private String createdByUsername;
private OffsetDateTime createdOn;
}
@@ -0,0 +1,32 @@
/**
* 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.useradministration.agreement.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
/**
* A {@link RuntimeException} thrown when there is no currently-active {@code UserAgreement} - i.e. nothing has
* ever been published via {@code POST /v1/useragreements}.
*/
public class UserAgreementNotFoundException extends AbstractPlatformResourceNotFoundException {
public UserAgreementNotFoundException() {
super("error.msg.useragreement.active.not.found", "There is currently no active user agreement.");
}
}
@@ -0,0 +1,29 @@
/**
* 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.useradministration.agreement.service;
import java.util.List;
import org.apache.fineract.useradministration.agreement.data.UserAgreementData;
public interface UserAgreementReadPlatformService {
UserAgreementData retrieveActive();
List<UserAgreementData> retrieveAll();
}
@@ -0,0 +1,49 @@
/**
* 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.useradministration.agreement.service;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.useradministration.agreement.data.UserAgreementData;
import org.apache.fineract.useradministration.agreement.domain.UserAgreement;
import org.apache.fineract.useradministration.agreement.domain.UserAgreementRepository;
import org.apache.fineract.useradministration.agreement.exception.UserAgreementNotFoundException;
@RequiredArgsConstructor
public class UserAgreementReadPlatformServiceImpl implements UserAgreementReadPlatformService {
private final UserAgreementRepository userAgreementRepository;
@Override
public UserAgreementData retrieveActive() {
final UserAgreement agreement = this.userAgreementRepository.findByActiveTrue()
.orElseThrow(UserAgreementNotFoundException::new);
return toData(agreement);
}
@Override
public List<UserAgreementData> retrieveAll() {
return this.userAgreementRepository.findAllByOrderByVersionDesc().stream().map(this::toData).toList();
}
private UserAgreementData toData(final UserAgreement agreement) {
return new UserAgreementData(agreement.getId(), agreement.getVersion(), agreement.getTitle(), agreement.getBody(),
agreement.isActive(), agreement.getCreatedBy().getUsername(), agreement.getCreatedOn());
}
}
@@ -0,0 +1,33 @@
/**
* 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.useradministration.agreement.service;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
public interface UserAgreementWritePlatformService {
CommandProcessingResult create(JsonCommand command);
/** Whether the currently authenticated user has already accepted the currently active agreement. */
boolean hasCurrentUserAcceptedActive();
/** Records the currently authenticated user's acceptance of the currently active agreement. */
void acceptActive(String ipAddress, String userAgent);
}
@@ -0,0 +1,109 @@
/**
* 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.useradministration.agreement.service;
import java.util.ArrayList;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.agreement.domain.UserAgreement;
import org.apache.fineract.useradministration.agreement.domain.UserAgreementAcceptance;
import org.apache.fineract.useradministration.agreement.domain.UserAgreementAcceptanceRepository;
import org.apache.fineract.useradministration.agreement.domain.UserAgreementRepository;
import org.apache.fineract.useradministration.agreement.exception.UserAgreementNotFoundException;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.transaction.annotation.Transactional;
@RequiredArgsConstructor
public class UserAgreementWritePlatformServiceImpl implements UserAgreementWritePlatformService {
public static final String TITLE = "title";
public static final String BODY = "body";
private final PlatformSecurityContext context;
private final UserAgreementRepository userAgreementRepository;
private final UserAgreementAcceptanceRepository userAgreementAcceptanceRepository;
@Override
@Transactional
public CommandProcessingResult create(final JsonCommand command) {
final AppUser currentUser = this.context.authenticatedUser();
final String title = command.stringValueOfParameterNamed(TITLE);
final String body = command.stringValueOfParameterNamed(BODY);
validate(title, body);
// Deactivate the currently-active agreement (if any) before activating the new one - this must happen
// in the same transaction as the insert below so a reader can never observe zero or two active rows.
this.userAgreementRepository.findByActiveTrue().ifPresent(UserAgreement::deactivate);
final Integer nextVersion = this.userAgreementRepository.findAllByOrderByVersionDesc().stream().findFirst()
.map(UserAgreement::getVersion).map(v -> v + 1).orElse(1);
final UserAgreement agreement = UserAgreement.create(nextVersion, title, body, currentUser);
this.userAgreementRepository.saveAndFlush(agreement);
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(agreement.getId()) //
.build();
}
@Override
public boolean hasCurrentUserAcceptedActive() {
final AppUser currentUser = this.context.authenticatedUser();
final UserAgreement active = this.userAgreementRepository.findByActiveTrue().orElse(null);
if (active == null) {
// No agreement has ever been published - nothing to accept.
return true;
}
return this.userAgreementAcceptanceRepository.existsByAppUser_IdAndAgreement_Id(currentUser.getId(), active.getId());
}
@Override
@Transactional
public void acceptActive(final String ipAddress, final String userAgent) {
final AppUser currentUser = this.context.authenticatedUser();
final UserAgreement active = this.userAgreementRepository.findByActiveTrue().orElseThrow(UserAgreementNotFoundException::new);
if (this.userAgreementAcceptanceRepository.existsByAppUser_IdAndAgreement_Id(currentUser.getId(), active.getId())) {
// Already accepted (e.g. duplicate click) - idempotent no-op.
return;
}
final UserAgreementAcceptance acceptance = UserAgreementAcceptance.create(currentUser, active, ipAddress, userAgent);
this.userAgreementAcceptanceRepository.saveAndFlush(acceptance);
}
private void validate(final String title, final String body) {
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors).resource("useragreement");
baseDataValidator.reset().parameter(TITLE).value(title).notBlank().notExceedingLengthOf(200);
baseDataValidator.reset().parameter(BODY).value(body).notBlank();
if (!dataValidationErrors.isEmpty()) {
throw new PlatformApiDataValidationException(dataValidationErrors);
}
}
}
@@ -124,6 +124,10 @@ final class UsersApiResourceSwagger {
public String email;
@Schema(example = "9999999999")
public String mobileNo;
@Schema(description = "Mandatory. Channel used to enroll this user's OTP-based 2FA at creation time - a verified "
+ "m_user_mfa_factor row is created immediately, so the existing 2FA system challenges them for OTP on their very "
+ "first login. One of EMAIL, SMS. SMS requires mobileNo to be set.", example = "EMAIL")
public String otpDeliveryMethod;
@Schema(example = "1")
public Long officeId;
@Schema(example = "1")
@@ -205,6 +209,10 @@ final class UsersApiResourceSwagger {
public String email;
@Schema(example = "9999999999")
public String mobileNo;
@Schema(description = "Optional. One of EMAIL, SMS. Currently accepted for validation parity with user creation but not "
+ "otherwise used on update - changing an existing user's enrolled 2FA channel is a separate Access Control action.",
example = "EMAIL")
public String otpDeliveryMethod;
@Schema(example = "1")
public Long officeId;
@Schema(example = "1")
@@ -0,0 +1,46 @@
/**
* 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.useradministration.handler;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.useradministration.agreement.service.UserAgreementWritePlatformService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "USERAGREEMENT", action = "CREATE")
public class CreateUserAgreementCommandHandler implements NewCommandSourceHandler {
private final UserAgreementWritePlatformService writePlatformService;
@Autowired
public CreateUserAgreementCommandHandler(final UserAgreementWritePlatformService writePlatformService) {
this.writePlatformService = writePlatformService;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.create(command);
}
}
@@ -23,6 +23,8 @@ import static org.apache.fineract.useradministration.service.AppUserConstants.CL
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import jakarta.persistence.PersistenceException;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
@@ -59,6 +61,9 @@ import org.apache.fineract.useradministration.domain.UserDomainService;
import org.apache.fineract.useradministration.exception.PasswordPreviouslyUsedException;
import org.apache.fineract.useradministration.exception.RoleNotFoundException;
import org.apache.fineract.useradministration.exception.UserNotFoundException;
import org.apache.fineract.useradministration.mfa.domain.MfaFactorType;
import org.apache.fineract.useradministration.mfa.domain.UserMfaFactor;
import org.apache.fineract.useradministration.mfa.domain.UserMfaFactorRepository;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Caching;
import org.springframework.dao.DataIntegrityViolationException;
@@ -84,6 +89,7 @@ public class AppUserWritePlatformServiceJpaRepositoryImpl implements AppUserWrit
private final StaffRepositoryWrapper staffRepositoryWrapper;
private final ClientRepositoryWrapper clientRepositoryWrapper;
private final ConfigurationDomainService configurationDomainService;
private final UserMfaFactorRepository userMfaFactorRepository;
@Override
@Transactional
@@ -143,6 +149,24 @@ public class AppUserWritePlatformServiceJpaRepositoryImpl implements AppUserWrit
final Boolean sendPasswordToEmail = command.booleanObjectValueOfParameterNamed("sendPasswordToEmail");
this.userDomainService.create(appUser, sendPasswordToEmail);
// Admin picks the OTP delivery channel for this user at creation time (mandatory field
// in the Create User form). Enrolling it here as an already-verified MFA factor means the
// existing 2FA system (MfaFactorBackedTwoFactorServiceImpl + /twofactor + the
// TwoFactorAuthenticationComponent login dialog) challenges this user for OTP on their very
// first login, with no separate first-login flow needed - it's the same experience any
// MFA-enrolled user already gets.
final String otpDeliveryMethod = command.stringValueOfParameterNamed("otpDeliveryMethod");
if (otpDeliveryMethod != null) {
final MfaFactorType factorType = MfaFactorType.fromString(otpDeliveryMethod);
final UserMfaFactor factor = new UserMfaFactor();
factor.setAppUser(appUser);
factor.setFactorType(factorType);
factor.setDestination(factorType == MfaFactorType.SMS ? appUser.getMobileNo() : appUser.getEmail());
factor.setVerified(true);
factor.setVerifiedOn(OffsetDateTime.now(ZoneOffset.UTC));
this.userMfaFactorRepository.save(factor);
}
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(appUser.getId()) //
@@ -59,15 +59,18 @@ public final class UserDataValidator {
public static final String SEND_PASSWORD_TO_EMAIL = "sendPasswordToEmail";
public static final String STAFF_ID = "staffId";
public static final String PASSWORD_NEVER_EXPIRES = "passwordNeverExpires";
public static final String OTP_DELIVERY_METHOD = "otpDeliveryMethod";
/**
* The parameters supported for this command.
*/
private static final Set<String> CREATE_SUPPORTED_PARAMETERS = new HashSet<>(
Arrays.asList(USERNAME, FIRSTNAME, LASTNAME, PASSWORD, REPEAT_PASSWORD, EMAIL, MOBILE_NO, OFFICE_ID, NOT_SELECTED_ROLES,
ROLES, SEND_PASSWORD_TO_EMAIL, STAFF_ID, PASSWORD_NEVER_EXPIRES, AppUserConstants.IS_SELF_SERVICE_USER, CLIENTS));
ROLES, SEND_PASSWORD_TO_EMAIL, STAFF_ID, PASSWORD_NEVER_EXPIRES, AppUserConstants.IS_SELF_SERVICE_USER, CLIENTS,
OTP_DELIVERY_METHOD));
private static final Set<String> UPDATE_SUPPORTED_PARAMETERS = new HashSet<>(
Arrays.asList(USERNAME, FIRSTNAME, LASTNAME, PASSWORD, REPEAT_PASSWORD, EMAIL, MOBILE_NO, OFFICE_ID, NOT_SELECTED_ROLES,
ROLES, SEND_PASSWORD_TO_EMAIL, STAFF_ID, PASSWORD_NEVER_EXPIRES, AppUserConstants.IS_SELF_SERVICE_USER, CLIENTS));
ROLES, SEND_PASSWORD_TO_EMAIL, STAFF_ID, PASSWORD_NEVER_EXPIRES, AppUserConstants.IS_SELF_SERVICE_USER, CLIENTS,
OTP_DELIVERY_METHOD));
private static final Set<String> CHANGE_PASSWORD_SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList(PASSWORD, REPEAT_PASSWORD));
public static final String PASSWORD_NEVER_EXPIRE = "passwordNeverExpire";
@@ -120,6 +123,14 @@ public final class UserDataValidator {
final Long officeId = this.fromApiJsonHelper.extractLongNamed(OFFICE_ID, element);
baseDataValidator.reset().parameter(OFFICE_ID).value(officeId).notNull().integerGreaterThanZero();
final String otpDeliveryMethod = this.fromApiJsonHelper.extractStringNamed(OTP_DELIVERY_METHOD, element);
baseDataValidator.reset().parameter(OTP_DELIVERY_METHOD).value(otpDeliveryMethod).notBlank()
.isOneOfTheseStringValues(Arrays.asList("EMAIL", "SMS"));
if ("SMS".equalsIgnoreCase(otpDeliveryMethod)) {
final String mobileNo = this.fromApiJsonHelper.extractStringNamed(MOBILE_NO, element);
baseDataValidator.reset().parameter(MOBILE_NO).value(mobileNo).notBlank();
}
if (this.fromApiJsonHelper.parameterExists(STAFF_ID, element)) {
final Long staffId = this.fromApiJsonHelper.extractLongNamed(STAFF_ID, element);
baseDataValidator.reset().parameter(STAFF_ID).value(staffId).notNull().integerGreaterThanZero();
@@ -258,6 +269,12 @@ public final class UserDataValidator {
baseDataValidator.reset().parameter(EMAIL).value(email).notBlank().notExceedingLengthOf(100);
}
if (this.fromApiJsonHelper.parameterExists(OTP_DELIVERY_METHOD, element)) {
final String otpDeliveryMethod = this.fromApiJsonHelper.extractStringNamed(OTP_DELIVERY_METHOD, element);
baseDataValidator.reset().parameter(OTP_DELIVERY_METHOD).value(otpDeliveryMethod).notBlank()
.isOneOfTheseStringValues(Arrays.asList("EMAIL", "SMS"));
}
if (this.fromApiJsonHelper.parameterExists(ROLES, element)) {
final String[] roles = this.fromApiJsonHelper.extractArrayNamed(ROLES, element);
baseDataValidator.reset().parameter(ROLES).value(roles).arrayNotEmpty();
@@ -52,6 +52,13 @@ import org.apache.fineract.useradministration.service.RoleReadPlatformService;
import org.apache.fineract.useradministration.service.RoleReadPlatformServiceImpl;
import org.apache.fineract.useradministration.service.RoleWritePlatformService;
import org.apache.fineract.useradministration.service.RoleWritePlatformServiceJpaRepositoryImpl;
import org.apache.fineract.useradministration.agreement.domain.UserAgreementAcceptanceRepository;
import org.apache.fineract.useradministration.agreement.domain.UserAgreementRepository;
import org.apache.fineract.useradministration.agreement.service.UserAgreementReadPlatformService;
import org.apache.fineract.useradministration.agreement.service.UserAgreementReadPlatformServiceImpl;
import org.apache.fineract.useradministration.agreement.service.UserAgreementWritePlatformService;
import org.apache.fineract.useradministration.agreement.service.UserAgreementWritePlatformServiceImpl;
import org.apache.fineract.useradministration.mfa.domain.UserMfaFactorRepository;
import org.apache.fineract.useradministration.service.UserDataValidator;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
@@ -76,10 +83,11 @@ public class UserAdministrationConfiguration {
PlatformPasswordEncoder platformPasswordEncoder, AppUserRepository appUserRepository,
OfficeRepositoryWrapper officeRepositoryWrapper, RoleRepository roleRepository, UserDataValidator fromApiJsonDeserializer,
AppUserPreviousPasswordRepository appUserPreviewPasswordRepository, StaffRepositoryWrapper staffRepositoryWrapper,
ClientRepositoryWrapper clientRepositoryWrapper, ConfigurationDomainService configurationDomainService) {
ClientRepositoryWrapper clientRepositoryWrapper, ConfigurationDomainService configurationDomainService,
UserMfaFactorRepository userMfaFactorRepository) {
return new AppUserWritePlatformServiceJpaRepositoryImpl(context, userDomainService, platformPasswordEncoder, appUserRepository,
officeRepositoryWrapper, roleRepository, fromApiJsonDeserializer, appUserPreviewPasswordRepository, staffRepositoryWrapper,
clientRepositoryWrapper, configurationDomainService);
clientRepositoryWrapper, configurationDomainService, userMfaFactorRepository);
}
@Bean
@@ -124,4 +132,17 @@ public class UserAdministrationConfiguration {
return new RoleWritePlatformServiceJpaRepositoryImpl(context, roleRepository, permissionRepository,
roleCommandFromApiJsonDeserializer, permissionsFromApiJsonDeserializer);
}
@Bean
@ConditionalOnMissingBean(UserAgreementReadPlatformService.class)
public UserAgreementReadPlatformService userAgreementReadPlatformService(UserAgreementRepository userAgreementRepository) {
return new UserAgreementReadPlatformServiceImpl(userAgreementRepository);
}
@Bean
@ConditionalOnMissingBean(UserAgreementWritePlatformService.class)
public UserAgreementWritePlatformService userAgreementWritePlatformService(PlatformSecurityContext context,
UserAgreementRepository userAgreementRepository, UserAgreementAcceptanceRepository userAgreementAcceptanceRepository) {
return new UserAgreementWritePlatformServiceImpl(context, userAgreementRepository, userAgreementAcceptanceRepository);
}
}
@@ -435,6 +435,12 @@
<include file="parts/0412_activate_outward_clearing_batch_job.xml" relativeToChangelogFile="true"/>
<include file="parts/0413_inward_item_verify_signature_permission.xml" relativeToChangelogFile="true"/>
<include file="parts/0414_inward_item_full_source_details.xml" relativeToChangelogFile="true"/>
<include file="parts/0415_add_otp_delivery_method_and_first_login_gate_to_appuser.xml" relativeToChangelogFile="true"/>
<include file="parts/0416_create_user_agreement_tables.xml" relativeToChangelogFile="true"/>
<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,35 @@
<?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.3.xsd">
<changeSet author="fineract" id="1">
<addColumn tableName="m_appuser">
<column name="otp_delivery_method" type="varchar(10)">
<constraints nullable="true"/>
</column>
<column name="first_login_gate_pending" type="boolean" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -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">
<!--
Versioned admin-manageable "user agreement" text (bank policy/warning) that newly-created users
must accept during the first-login gate. Only one row may be active at a time - enforced in the
write service (UserAgreementWritePlatformServiceImpl), not a DB constraint, since flipping "active"
is a two-row transaction (deactivate old, activate new).
-->
<changeSet id="0416_create_m_user_agreement" author="fineract">
<preConditions onFail="MARK_RAN"><not><tableExists tableName="m_user_agreement"/></not></preConditions>
<createTable tableName="m_user_agreement">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints nullable="false" primaryKey="true"/>
</column>
<column name="version" type="INT">
<constraints nullable="false"/>
</column>
<column name="title" type="VARCHAR(200)">
<constraints nullable="false"/>
</column>
<column name="body" type="TEXT">
<constraints nullable="false"/>
</column>
<column name="is_active" type="boolean" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
<column name="created_by" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="created_on" type="datetime" defaultValueComputed="CURRENT_TIMESTAMP">
<constraints nullable="false"/>
</column>
</createTable>
<addUniqueConstraint tableName="m_user_agreement" columnNames="version"
constraintName="uq_user_agreement_version"/>
<addForeignKeyConstraint baseTableName="m_user_agreement" baseColumnNames="created_by"
constraintName="fk_user_agreement_created_by"
referencedTableName="m_appuser" referencedColumnNames="id"/>
</changeSet>
<!--
Audit-proof record of a user accepting a given version of the agreement during the first-login gate.
agreement_version is a denormalized snapshot of m_user_agreement.version at acceptance time, so the
acceptance record survives future version bumps unaffected.
-->
<changeSet id="0416_create_m_user_agreement_acceptance" author="fineract">
<preConditions onFail="MARK_RAN"><not><tableExists tableName="m_user_agreement_acceptance"/></not></preConditions>
<createTable tableName="m_user_agreement_acceptance">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints nullable="false" primaryKey="true"/>
</column>
<column name="appuser_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="agreement_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="agreement_version" type="INT">
<constraints nullable="false"/>
</column>
<column name="accepted_on" type="datetime" defaultValueComputed="CURRENT_TIMESTAMP">
<constraints nullable="false"/>
</column>
<column name="ip_address" type="VARCHAR(45)"/>
<column name="user_agent" type="VARCHAR(500)"/>
</createTable>
<addUniqueConstraint tableName="m_user_agreement_acceptance" columnNames="appuser_id, agreement_id"
constraintName="uq_user_agreement_acceptance"/>
<addForeignKeyConstraint baseTableName="m_user_agreement_acceptance" baseColumnNames="appuser_id"
constraintName="fk_user_agreement_acceptance_user"
referencedTableName="m_appuser" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_user_agreement_acceptance" baseColumnNames="agreement_id"
constraintName="fk_user_agreement_acceptance_agreement"
referencedTableName="m_user_agreement" referencedColumnNames="id"/>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,34 @@
<?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.3.xsd">
<changeSet author="fineract" id="1">
<insert tableName="c_configuration">
<column name="name" value="enable-first-login-otp-and-agreement-verification"/>
<column name="value" valueNumeric="0"/>
<column name="enabled" valueBoolean="false"/>
<column name="is_trap_door" valueBoolean="false"/>
<column name="description" value="If enabled, newly created users must verify OTP via their chosen channel and accept the active user agreement on first login before access is granted. Verification also auto-enrolls that channel as ongoing MFA. Existing users are unaffected. Value unused."/>
</insert>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<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">
<!--
Permissions for the admin "user agreement" management API (/v1/useragreements), used to
publish the versioned agreement text every user must accept right after logging in.
-->
<changeSet id="0418-user-agreement-create-permission" author="CBS" runOnChange="false">
<sql>
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'organisation', 'CREATE_USERAGREEMENT', 'USERAGREEMENT', 'CREATE', false
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'CREATE_USERAGREEMENT');
</sql>
</changeSet>
<changeSet id="0418-user-agreement-read-permission" author="CBS" runOnChange="false">
<sql>
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'organisation', 'READ_USERAGREEMENT', 'USERAGREEMENT', 'READ', false
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'READ_USERAGREEMENT');
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,55 @@
<?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">
<!--
Reverts 0415/0417: the first-login "gate" (its own status/complete API, pending-flag, and
dedicated toggle) was replaced by simply auto-enrolling the admin-chosen OTP channel as an
already-verified m_user_mfa_factor row at user-creation time - the existing 2FA system
(MfaFactorBackedTwoFactorServiceImpl + /twofactor + TwoFactorAuthenticationComponent) then
challenges the user for OTP on their very first login with no new code at all. Neither
column nor the config toggle are read by any code anymore.
-->
<changeSet id="0419-drop-otp-delivery-method-column" author="CBS">
<preConditions onFail="MARK_RAN">
<columnExists tableName="m_appuser" columnName="otp_delivery_method"/>
</preConditions>
<dropColumn tableName="m_appuser" columnName="otp_delivery_method"/>
</changeSet>
<changeSet id="0419-drop-first-login-gate-pending-column" author="CBS">
<preConditions onFail="MARK_RAN">
<columnExists tableName="m_appuser" columnName="first_login_gate_pending"/>
</preConditions>
<dropColumn tableName="m_appuser" columnName="first_login_gate_pending"/>
</changeSet>
<changeSet id="0419-remove-first-login-gate-config" author="CBS">
<sql>
DELETE FROM c_configuration WHERE name = 'enable-first-login-otp-and-agreement-verification';
</sql>
</changeSet>
</databaseChangeLog>
@@ -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>
@@ -0,0 +1,53 @@
/**
* 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.infrastructure.security.otp.util;
/**
* Shared masking logic for OTP delivery destinations (mobile number / email address), so the destination can be
* shown to a user for confirmation ("OTP sent to ******1234") without exposing the full value.
*
* <p>
* Extracted, verbatim, from the private {@code mask()} methods that previously lived independently in
* {@code LoginOtpServiceImpl} (SMS) and {@code EmailLoginOtpServiceImpl} (Email) — this is a pure refactor with no
* behaviour change; both services now delegate here instead of maintaining their own copy.
*/
public final class OtpDestinationMasker {
private OtpDestinationMasker() {}
public static String maskMobile(final String mobile) {
if (mobile == null || mobile.length() < 4) {
return "****";
}
return "******" + mobile.substring(mobile.length() - 4);
}
public static String maskEmail(final String email) {
if (email == null || !email.contains("@")) {
return "****";
}
final int at = email.indexOf('@');
final String local = email.substring(0, at);
final String domain = email.substring(at);
if (local.length() <= 2) {
return "*" + local.substring(local.length() - 1) + domain;
}
return local.charAt(0) + "***" + local.charAt(local.length() - 1) + domain;
}
}