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
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>
This commit is contained in:
+8
@@ -640,6 +640,14 @@ public class CommandWrapperBuilder {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public CommandWrapperBuilder createUserAgreement() {
|
||||||
|
this.actionName = "CREATE";
|
||||||
|
this.entityName = "USERAGREEMENT";
|
||||||
|
this.entityId = null;
|
||||||
|
this.href = "/useragreements";
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
public CommandWrapperBuilder createOffice() {
|
public CommandWrapperBuilder createOffice() {
|
||||||
this.actionName = "CREATE";
|
this.actionName = "CREATE";
|
||||||
this.entityName = "OFFICE";
|
this.entityName = "OFFICE";
|
||||||
|
|||||||
+92
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+87
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
@@ -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);
|
||||||
|
}
|
||||||
+30
@@ -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();
|
||||||
|
}
|
||||||
+3
-15
@@ -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.data.OtpVerifyResponse;
|
||||||
import org.apache.fineract.infrastructure.security.otp.domain.OtpLog;
|
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.domain.OtpLogRepository;
|
||||||
|
import org.apache.fineract.infrastructure.security.otp.util.OtpDestinationMasker;
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
import org.apache.fineract.useradministration.domain.AppUserRepository;
|
import org.apache.fineract.useradministration.domain.AppUserRepository;
|
||||||
import org.apache.fineract.useradministration.mfa.domain.MfaFactorType;
|
import org.apache.fineract.useradministration.mfa.domain.MfaFactorType;
|
||||||
@@ -133,11 +134,11 @@ public class EmailLoginOtpServiceImpl implements EmailLoginOtpService {
|
|||||||
if (sent) {
|
if (sent) {
|
||||||
log.setStatus(STATUS_SENT);
|
log.setStatus(STATUS_SENT);
|
||||||
final OtpLog saved = otpLogRepository.saveAndFlush(log);
|
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()
|
return OtpGenerateResponse.builder()
|
||||||
.success(true)
|
.success(true)
|
||||||
.otpId(saved.getId())
|
.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)
|
.expiresInSeconds(ttlSeconds)
|
||||||
.message("OTP sent to your registered email")
|
.message("OTP sent to your registered email")
|
||||||
.build();
|
.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) {
|
private String displayName(final AppUser user) {
|
||||||
if (user.getFirstname() != null && !user.getFirstname().isBlank()) {
|
if (user.getFirstname() != null && !user.getFirstname().isBlank()) {
|
||||||
return user.getFirstname();
|
return user.getFirstname();
|
||||||
|
|||||||
+4
-10
@@ -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.data.OtpVerifyResponse;
|
||||||
import org.apache.fineract.infrastructure.security.otp.domain.OtpLog;
|
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.domain.OtpLogRepository;
|
||||||
|
import org.apache.fineract.infrastructure.security.otp.util.OtpDestinationMasker;
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
import org.apache.fineract.useradministration.domain.AppUserRepository;
|
import org.apache.fineract.useradministration.domain.AppUserRepository;
|
||||||
import org.apache.fineract.useradministration.mfa.domain.MfaFactorType;
|
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",
|
log.setOtpReference(extractField(response, "reference_id", "refNumber", "ref_number",
|
||||||
"transactionId", "txnId", "requestId"));
|
"transactionId", "txnId", "requestId"));
|
||||||
final OtpLog saved = otpLogRepository.saveAndFlush(log);
|
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()
|
return OtpGenerateResponse.builder()
|
||||||
.success(true)
|
.success(true)
|
||||||
.otpId(saved.getId())
|
.otpId(saved.getId())
|
||||||
.otpReference(saved.getOtpReference())
|
.otpReference(saved.getOtpReference())
|
||||||
.maskedMobile(mask(mobileNumber))
|
.maskedMobile(OtpDestinationMasker.maskMobile(mobileNumber))
|
||||||
.expiresInSeconds(ttlSeconds)
|
.expiresInSeconds(ttlSeconds)
|
||||||
.message("OTP sent")
|
.message("OTP sent")
|
||||||
.build();
|
.build();
|
||||||
@@ -239,7 +240,7 @@ public class LoginOtpServiceImpl implements LoginOtpService {
|
|||||||
row.setVerifiedAt(OffsetDateTime.now(ZoneOffset.UTC));
|
row.setVerifiedAt(OffsetDateTime.now(ZoneOffset.UTC));
|
||||||
otpLogRepository.saveAndFlush(row);
|
otpLogRepository.saveAndFlush(row);
|
||||||
autoPromoteFactor(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();
|
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) {
|
private String truncate(final String s, final int max) {
|
||||||
if (s == null) {
|
if (s == null) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
+162
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+41
@@ -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;
|
||||||
|
}
|
||||||
+32
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
+29
@@ -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();
|
||||||
|
}
|
||||||
+49
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
@@ -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);
|
||||||
|
}
|
||||||
+109
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
@@ -124,6 +124,10 @@ final class UsersApiResourceSwagger {
|
|||||||
public String email;
|
public String email;
|
||||||
@Schema(example = "9999999999")
|
@Schema(example = "9999999999")
|
||||||
public String mobileNo;
|
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")
|
@Schema(example = "1")
|
||||||
public Long officeId;
|
public Long officeId;
|
||||||
@Schema(example = "1")
|
@Schema(example = "1")
|
||||||
@@ -205,6 +209,10 @@ final class UsersApiResourceSwagger {
|
|||||||
public String email;
|
public String email;
|
||||||
@Schema(example = "9999999999")
|
@Schema(example = "9999999999")
|
||||||
public String mobileNo;
|
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")
|
@Schema(example = "1")
|
||||||
public Long officeId;
|
public Long officeId;
|
||||||
@Schema(example = "1")
|
@Schema(example = "1")
|
||||||
|
|||||||
+46
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+24
@@ -23,6 +23,8 @@ import static org.apache.fineract.useradministration.service.AppUserConstants.CL
|
|||||||
import com.google.gson.JsonArray;
|
import com.google.gson.JsonArray;
|
||||||
import com.google.gson.JsonElement;
|
import com.google.gson.JsonElement;
|
||||||
import jakarta.persistence.PersistenceException;
|
import jakarta.persistence.PersistenceException;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
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.PasswordPreviouslyUsedException;
|
||||||
import org.apache.fineract.useradministration.exception.RoleNotFoundException;
|
import org.apache.fineract.useradministration.exception.RoleNotFoundException;
|
||||||
import org.apache.fineract.useradministration.exception.UserNotFoundException;
|
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.CacheEvict;
|
||||||
import org.springframework.cache.annotation.Caching;
|
import org.springframework.cache.annotation.Caching;
|
||||||
import org.springframework.dao.DataIntegrityViolationException;
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
@@ -84,6 +89,7 @@ public class AppUserWritePlatformServiceJpaRepositoryImpl implements AppUserWrit
|
|||||||
private final StaffRepositoryWrapper staffRepositoryWrapper;
|
private final StaffRepositoryWrapper staffRepositoryWrapper;
|
||||||
private final ClientRepositoryWrapper clientRepositoryWrapper;
|
private final ClientRepositoryWrapper clientRepositoryWrapper;
|
||||||
private final ConfigurationDomainService configurationDomainService;
|
private final ConfigurationDomainService configurationDomainService;
|
||||||
|
private final UserMfaFactorRepository userMfaFactorRepository;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -143,6 +149,24 @@ public class AppUserWritePlatformServiceJpaRepositoryImpl implements AppUserWrit
|
|||||||
final Boolean sendPasswordToEmail = command.booleanObjectValueOfParameterNamed("sendPasswordToEmail");
|
final Boolean sendPasswordToEmail = command.booleanObjectValueOfParameterNamed("sendPasswordToEmail");
|
||||||
this.userDomainService.create(appUser, 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() //
|
return new CommandProcessingResultBuilder() //
|
||||||
.withCommandId(command.commandId()) //
|
.withCommandId(command.commandId()) //
|
||||||
.withEntityId(appUser.getId()) //
|
.withEntityId(appUser.getId()) //
|
||||||
|
|||||||
+19
-2
@@ -59,15 +59,18 @@ public final class UserDataValidator {
|
|||||||
public static final String SEND_PASSWORD_TO_EMAIL = "sendPasswordToEmail";
|
public static final String SEND_PASSWORD_TO_EMAIL = "sendPasswordToEmail";
|
||||||
public static final String STAFF_ID = "staffId";
|
public static final String STAFF_ID = "staffId";
|
||||||
public static final String PASSWORD_NEVER_EXPIRES = "passwordNeverExpires";
|
public static final String PASSWORD_NEVER_EXPIRES = "passwordNeverExpires";
|
||||||
|
public static final String OTP_DELIVERY_METHOD = "otpDeliveryMethod";
|
||||||
/**
|
/**
|
||||||
* The parameters supported for this command.
|
* The parameters supported for this command.
|
||||||
*/
|
*/
|
||||||
private static final Set<String> CREATE_SUPPORTED_PARAMETERS = new HashSet<>(
|
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,
|
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<>(
|
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,
|
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));
|
private static final Set<String> CHANGE_PASSWORD_SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList(PASSWORD, REPEAT_PASSWORD));
|
||||||
public static final String PASSWORD_NEVER_EXPIRE = "passwordNeverExpire";
|
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);
|
final Long officeId = this.fromApiJsonHelper.extractLongNamed(OFFICE_ID, element);
|
||||||
baseDataValidator.reset().parameter(OFFICE_ID).value(officeId).notNull().integerGreaterThanZero();
|
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)) {
|
if (this.fromApiJsonHelper.parameterExists(STAFF_ID, element)) {
|
||||||
final Long staffId = this.fromApiJsonHelper.extractLongNamed(STAFF_ID, element);
|
final Long staffId = this.fromApiJsonHelper.extractLongNamed(STAFF_ID, element);
|
||||||
baseDataValidator.reset().parameter(STAFF_ID).value(staffId).notNull().integerGreaterThanZero();
|
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);
|
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)) {
|
if (this.fromApiJsonHelper.parameterExists(ROLES, element)) {
|
||||||
final String[] roles = this.fromApiJsonHelper.extractArrayNamed(ROLES, element);
|
final String[] roles = this.fromApiJsonHelper.extractArrayNamed(ROLES, element);
|
||||||
baseDataValidator.reset().parameter(ROLES).value(roles).arrayNotEmpty();
|
baseDataValidator.reset().parameter(ROLES).value(roles).arrayNotEmpty();
|
||||||
|
|||||||
+23
-2
@@ -52,6 +52,13 @@ import org.apache.fineract.useradministration.service.RoleReadPlatformService;
|
|||||||
import org.apache.fineract.useradministration.service.RoleReadPlatformServiceImpl;
|
import org.apache.fineract.useradministration.service.RoleReadPlatformServiceImpl;
|
||||||
import org.apache.fineract.useradministration.service.RoleWritePlatformService;
|
import org.apache.fineract.useradministration.service.RoleWritePlatformService;
|
||||||
import org.apache.fineract.useradministration.service.RoleWritePlatformServiceJpaRepositoryImpl;
|
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.apache.fineract.useradministration.service.UserDataValidator;
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
@@ -76,10 +83,11 @@ public class UserAdministrationConfiguration {
|
|||||||
PlatformPasswordEncoder platformPasswordEncoder, AppUserRepository appUserRepository,
|
PlatformPasswordEncoder platformPasswordEncoder, AppUserRepository appUserRepository,
|
||||||
OfficeRepositoryWrapper officeRepositoryWrapper, RoleRepository roleRepository, UserDataValidator fromApiJsonDeserializer,
|
OfficeRepositoryWrapper officeRepositoryWrapper, RoleRepository roleRepository, UserDataValidator fromApiJsonDeserializer,
|
||||||
AppUserPreviousPasswordRepository appUserPreviewPasswordRepository, StaffRepositoryWrapper staffRepositoryWrapper,
|
AppUserPreviousPasswordRepository appUserPreviewPasswordRepository, StaffRepositoryWrapper staffRepositoryWrapper,
|
||||||
ClientRepositoryWrapper clientRepositoryWrapper, ConfigurationDomainService configurationDomainService) {
|
ClientRepositoryWrapper clientRepositoryWrapper, ConfigurationDomainService configurationDomainService,
|
||||||
|
UserMfaFactorRepository userMfaFactorRepository) {
|
||||||
return new AppUserWritePlatformServiceJpaRepositoryImpl(context, userDomainService, platformPasswordEncoder, appUserRepository,
|
return new AppUserWritePlatformServiceJpaRepositoryImpl(context, userDomainService, platformPasswordEncoder, appUserRepository,
|
||||||
officeRepositoryWrapper, roleRepository, fromApiJsonDeserializer, appUserPreviewPasswordRepository, staffRepositoryWrapper,
|
officeRepositoryWrapper, roleRepository, fromApiJsonDeserializer, appUserPreviewPasswordRepository, staffRepositoryWrapper,
|
||||||
clientRepositoryWrapper, configurationDomainService);
|
clientRepositoryWrapper, configurationDomainService, userMfaFactorRepository);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
@@ -124,4 +132,17 @@ public class UserAdministrationConfiguration {
|
|||||||
return new RoleWritePlatformServiceJpaRepositoryImpl(context, roleRepository, permissionRepository,
|
return new RoleWritePlatformServiceJpaRepositoryImpl(context, roleRepository, permissionRepository,
|
||||||
roleCommandFromApiJsonDeserializer, permissionsFromApiJsonDeserializer);
|
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,11 @@
|
|||||||
<include file="parts/0412_activate_outward_clearing_batch_job.xml" relativeToChangelogFile="true"/>
|
<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/0413_inward_item_verify_signature_permission.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0414_inward_item_full_source_details.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"/>
|
||||||
<!-- NOTE: the numbers below (0341-0359) re-use prefixes already used above by the chequebook/CTS
|
<!-- 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
|
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
|
sequence independently. Liquibase tracks changesets by filename + id + author, not by the numeric
|
||||||
|
|||||||
+35
@@ -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>
|
||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
|
||||||
|
Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
or more contributor license agreements. See the NOTICE file
|
||||||
|
distributed with this work for additional information
|
||||||
|
regarding copyright ownership. The ASF licenses this file
|
||||||
|
to you under the Apache License, Version 2.0 (the
|
||||||
|
"License"); you may not use this file except in compliance
|
||||||
|
with the License. You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing,
|
||||||
|
software distributed under the License is distributed on an
|
||||||
|
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
KIND, either express or implied. See the License for the
|
||||||
|
specific language governing permissions and limitations
|
||||||
|
under the License.
|
||||||
|
|
||||||
|
-->
|
||||||
|
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||||
|
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
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>
|
||||||
+34
@@ -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>
|
||||||
+27
@@ -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>
|
||||||
+55
@@ -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>
|
||||||
+53
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user