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>
This commit is contained in:
2026-07-24 16:16:40 +05:30
co-authored by Claude Sonnet 5
parent cf60515bf6
commit 5dc175c5e1
26 changed files with 1140 additions and 29 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";
@@ -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();
}