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();
}
@@ -33,6 +33,7 @@ import org.apache.fineract.infrastructure.security.otp.data.OtpGenerateResponse;
import org.apache.fineract.infrastructure.security.otp.data.OtpVerifyResponse;
import org.apache.fineract.infrastructure.security.otp.domain.OtpLog;
import org.apache.fineract.infrastructure.security.otp.domain.OtpLogRepository;
import org.apache.fineract.infrastructure.security.otp.util.OtpDestinationMasker;
import org.apache.fineract.useradministration.domain.AppUser;
import org.apache.fineract.useradministration.domain.AppUserRepository;
import org.apache.fineract.useradministration.mfa.domain.MfaFactorType;
@@ -133,11 +134,11 @@ public class EmailLoginOtpServiceImpl implements EmailLoginOtpService {
if (sent) {
log.setStatus(STATUS_SENT);
final OtpLog saved = otpLogRepository.saveAndFlush(log);
LOG.info("Email login OTP sent. otpId={}, user={}, email={}", saved.getId(), username, mask(email));
LOG.info("Email login OTP sent. otpId={}, user={}, email={}", saved.getId(), username, OtpDestinationMasker.maskEmail(email));
return OtpGenerateResponse.builder()
.success(true)
.otpId(saved.getId())
.maskedMobile(mask(email)) // reusing the field; UI just shows where it was sent
.maskedMobile(OtpDestinationMasker.maskEmail(email)) // reusing the field; UI just shows where it was sent
.expiresInSeconds(ttlSeconds)
.message("OTP sent to your registered email")
.build();
@@ -240,19 +241,6 @@ public class EmailLoginOtpServiceImpl implements EmailLoginOtpService {
}
}
private String mask(final String email) {
if (email == null || !email.contains("@")) {
return "****";
}
final int at = email.indexOf('@');
final String local = email.substring(0, at);
final String domain = email.substring(at);
if (local.length() <= 2) {
return "*" + local.substring(local.length() - 1) + domain;
}
return local.charAt(0) + "***" + local.charAt(local.length() - 1) + domain;
}
private String displayName(final AppUser user) {
if (user.getFirstname() != null && !user.getFirstname().isBlank()) {
return user.getFirstname();
@@ -31,6 +31,7 @@ import org.apache.fineract.infrastructure.security.otp.data.OtpGenerateResponse;
import org.apache.fineract.infrastructure.security.otp.data.OtpVerifyResponse;
import org.apache.fineract.infrastructure.security.otp.domain.OtpLog;
import org.apache.fineract.infrastructure.security.otp.domain.OtpLogRepository;
import org.apache.fineract.infrastructure.security.otp.util.OtpDestinationMasker;
import org.apache.fineract.useradministration.domain.AppUser;
import org.apache.fineract.useradministration.domain.AppUserRepository;
import org.apache.fineract.useradministration.mfa.domain.MfaFactorType;
@@ -156,12 +157,12 @@ public class LoginOtpServiceImpl implements LoginOtpService {
log.setOtpReference(extractField(response, "reference_id", "refNumber", "ref_number",
"transactionId", "txnId", "requestId"));
final OtpLog saved = otpLogRepository.saveAndFlush(log);
LOG.info("LincPay login OTP sent. otpId={}, mobile={}", saved.getId(), mask(mobileNumber));
LOG.info("LincPay login OTP sent. otpId={}, mobile={}", saved.getId(), OtpDestinationMasker.maskMobile(mobileNumber));
return OtpGenerateResponse.builder()
.success(true)
.otpId(saved.getId())
.otpReference(saved.getOtpReference())
.maskedMobile(mask(mobileNumber))
.maskedMobile(OtpDestinationMasker.maskMobile(mobileNumber))
.expiresInSeconds(ttlSeconds)
.message("OTP sent")
.build();
@@ -239,7 +240,7 @@ public class LoginOtpServiceImpl implements LoginOtpService {
row.setVerifiedAt(OffsetDateTime.now(ZoneOffset.UTC));
otpLogRepository.saveAndFlush(row);
autoPromoteFactor(row);
LOG.info("LincPay login OTP verified. otpId={}, mobile={}", row.getId(), mask(row.getMobileNumber()));
LOG.info("LincPay login OTP verified. otpId={}, mobile={}", row.getId(), OtpDestinationMasker.maskMobile(row.getMobileNumber()));
return OtpVerifyResponse.builder().verified(true).message("OTP verified").build();
}
@@ -364,13 +365,6 @@ public class LoginOtpServiceImpl implements LoginOtpService {
}
}
private String mask(final String mobile) {
if (mobile == null || mobile.length() < 4) {
return "****";
}
return "******" + mobile.substring(mobile.length() - 4);
}
private String truncate(final String s, final int max) {
if (s == null) {
return null;
@@ -0,0 +1,162 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.useradministration.agreement.api;
import com.google.gson.JsonObject;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.agreement.data.UserAgreementData;
import org.apache.fineract.useradministration.agreement.service.UserAgreementReadPlatformService;
import org.apache.fineract.useradministration.agreement.service.UserAgreementWritePlatformService;
import org.springframework.stereotype.Component;
/**
* Admin API for publishing/reading the versioned "user agreement" text (bank policy/warning) shown to every user
* right after they finish logging in (OTP included, if their account has it enrolled). {@code retrieveAll()},
* {@code retrieveActive()} and {@code create()} are admin-only (gated by USERAGREEMENT permissions) for managing the
* text itself. {@code status()} and {@code accept()} are for the ordinary end-user post-login flow - any
* authenticated user may call them for their own acceptance record, no special permission required.
*/
@Path("/v1/useragreements")
@Component
@Tag(name = "User Agreements", description = "Publish/read versioned user-agreement text (admin) and check/record the current user's own acceptance of it (post-login).")
@RequiredArgsConstructor
public class UserAgreementApiResource {
private static final String RESOURCE_NAME_FOR_PERMISSIONS = "USERAGREEMENT";
private final PlatformSecurityContext context;
private final UserAgreementReadPlatformService readPlatformService;
private final UserAgreementWritePlatformService writePlatformService;
private final DefaultToApiJsonSerializer<UserAgreementData> toApiJsonSerializer;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List all User Agreement versions", description = "Newest version first.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "OK", content = @Content(array = @ArraySchema(schema = @Schema(implementation = UserAgreementData.class)))) })
public String retrieveAll() {
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
final List<UserAgreementData> all = this.readPlatformService.retrieveAll();
return this.toApiJsonSerializer.serialize(all);
}
@GET
@Path("active")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve the currently active User Agreement", description = "The version every user must accept after logging in.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = UserAgreementData.class))) })
public String retrieveActive() {
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
final UserAgreementData active = this.readPlatformService.retrieveActive();
return this.toApiJsonSerializer.serialize(active);
}
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Publish a new User Agreement version", description = "Transactionally deactivates the previously-active version "
+ "and activates the new one; version number is server-assigned (currentMax + 1). Mandatory fields: title, body.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = CommandProcessingResult.class))) })
public String create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
.createUserAgreement() //
.withJson(apiRequestBodyAsJson) //
.build();
final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
return this.toApiJsonSerializer.serialize(result);
}
@GET
@Path("active/status")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Check whether the current user has accepted the active agreement", description = "Called right after "
+ "login (OTP included, if the account has it enrolled) to decide whether to show the agreement dialog. No special "
+ "permission required beyond being logged in - reflects only the caller's own acceptance record. Includes the agreement "
+ "text when not yet accepted, so the client doesn't need a second round-trip.")
public String status() {
final boolean accepted = this.writePlatformService.hasCurrentUserAcceptedActive();
final JsonObject json = new JsonObject();
json.addProperty("accepted", accepted);
if (!accepted) {
final UserAgreementData active = this.readPlatformService.retrieveActive();
json.addProperty("id", active.getId());
json.addProperty("version", active.getVersion());
json.addProperty("title", active.getTitle());
json.addProperty("body", active.getBody());
}
return json.toString();
}
@POST
@Path("active/accept")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Accept the currently active agreement", description = "Records the caller's own acceptance. Idempotent - "
+ "calling it again after already accepting is a no-op.")
public String accept(@Context final HttpServletRequest httpRequest) {
final String ipAddress = clientIp(httpRequest);
final String userAgent = httpRequest != null ? httpRequest.getHeader("User-Agent") : null;
this.writePlatformService.acceptActive(ipAddress, userAgent);
final JsonObject json = new JsonObject();
json.addProperty("accepted", true);
return json.toString();
}
private String clientIp(final HttpServletRequest req) {
if (req == null) {
return null;
}
final String fwd = req.getHeader("X-Forwarded-For");
if (fwd != null && !fwd.isBlank()) {
return fwd.split(",")[0].trim();
}
return req.getRemoteAddr();
}
}
@@ -0,0 +1,41 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.useradministration.agreement.data;
import java.io.Serializable;
import java.time.OffsetDateTime;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
@ToString
@AllArgsConstructor
@Getter
@Setter
public class UserAgreementData implements Serializable {
private Long id;
private Integer version;
private String title;
private String body;
private boolean active;
private String createdByUsername;
private OffsetDateTime createdOn;
}
@@ -0,0 +1,32 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.useradministration.agreement.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
/**
* A {@link RuntimeException} thrown when there is no currently-active {@code UserAgreement} - i.e. nothing has
* ever been published via {@code POST /v1/useragreements}.
*/
public class UserAgreementNotFoundException extends AbstractPlatformResourceNotFoundException {
public UserAgreementNotFoundException() {
super("error.msg.useragreement.active.not.found", "There is currently no active user agreement.");
}
}
@@ -0,0 +1,29 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.useradministration.agreement.service;
import java.util.List;
import org.apache.fineract.useradministration.agreement.data.UserAgreementData;
public interface UserAgreementReadPlatformService {
UserAgreementData retrieveActive();
List<UserAgreementData> retrieveAll();
}
@@ -0,0 +1,49 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.useradministration.agreement.service;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.useradministration.agreement.data.UserAgreementData;
import org.apache.fineract.useradministration.agreement.domain.UserAgreement;
import org.apache.fineract.useradministration.agreement.domain.UserAgreementRepository;
import org.apache.fineract.useradministration.agreement.exception.UserAgreementNotFoundException;
@RequiredArgsConstructor
public class UserAgreementReadPlatformServiceImpl implements UserAgreementReadPlatformService {
private final UserAgreementRepository userAgreementRepository;
@Override
public UserAgreementData retrieveActive() {
final UserAgreement agreement = this.userAgreementRepository.findByActiveTrue()
.orElseThrow(UserAgreementNotFoundException::new);
return toData(agreement);
}
@Override
public List<UserAgreementData> retrieveAll() {
return this.userAgreementRepository.findAllByOrderByVersionDesc().stream().map(this::toData).toList();
}
private UserAgreementData toData(final UserAgreement agreement) {
return new UserAgreementData(agreement.getId(), agreement.getVersion(), agreement.getTitle(), agreement.getBody(),
agreement.isActive(), agreement.getCreatedBy().getUsername(), agreement.getCreatedOn());
}
}
@@ -0,0 +1,33 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.useradministration.agreement.service;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
public interface UserAgreementWritePlatformService {
CommandProcessingResult create(JsonCommand command);
/** Whether the currently authenticated user has already accepted the currently active agreement. */
boolean hasCurrentUserAcceptedActive();
/** Records the currently authenticated user's acceptance of the currently active agreement. */
void acceptActive(String ipAddress, String userAgent);
}
@@ -0,0 +1,109 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.useradministration.agreement.service;
import java.util.ArrayList;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.agreement.domain.UserAgreement;
import org.apache.fineract.useradministration.agreement.domain.UserAgreementAcceptance;
import org.apache.fineract.useradministration.agreement.domain.UserAgreementAcceptanceRepository;
import org.apache.fineract.useradministration.agreement.domain.UserAgreementRepository;
import org.apache.fineract.useradministration.agreement.exception.UserAgreementNotFoundException;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.transaction.annotation.Transactional;
@RequiredArgsConstructor
public class UserAgreementWritePlatformServiceImpl implements UserAgreementWritePlatformService {
public static final String TITLE = "title";
public static final String BODY = "body";
private final PlatformSecurityContext context;
private final UserAgreementRepository userAgreementRepository;
private final UserAgreementAcceptanceRepository userAgreementAcceptanceRepository;
@Override
@Transactional
public CommandProcessingResult create(final JsonCommand command) {
final AppUser currentUser = this.context.authenticatedUser();
final String title = command.stringValueOfParameterNamed(TITLE);
final String body = command.stringValueOfParameterNamed(BODY);
validate(title, body);
// Deactivate the currently-active agreement (if any) before activating the new one - this must happen
// in the same transaction as the insert below so a reader can never observe zero or two active rows.
this.userAgreementRepository.findByActiveTrue().ifPresent(UserAgreement::deactivate);
final Integer nextVersion = this.userAgreementRepository.findAllByOrderByVersionDesc().stream().findFirst()
.map(UserAgreement::getVersion).map(v -> v + 1).orElse(1);
final UserAgreement agreement = UserAgreement.create(nextVersion, title, body, currentUser);
this.userAgreementRepository.saveAndFlush(agreement);
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(agreement.getId()) //
.build();
}
@Override
public boolean hasCurrentUserAcceptedActive() {
final AppUser currentUser = this.context.authenticatedUser();
final UserAgreement active = this.userAgreementRepository.findByActiveTrue().orElse(null);
if (active == null) {
// No agreement has ever been published - nothing to accept.
return true;
}
return this.userAgreementAcceptanceRepository.existsByAppUser_IdAndAgreement_Id(currentUser.getId(), active.getId());
}
@Override
@Transactional
public void acceptActive(final String ipAddress, final String userAgent) {
final AppUser currentUser = this.context.authenticatedUser();
final UserAgreement active = this.userAgreementRepository.findByActiveTrue().orElseThrow(UserAgreementNotFoundException::new);
if (this.userAgreementAcceptanceRepository.existsByAppUser_IdAndAgreement_Id(currentUser.getId(), active.getId())) {
// Already accepted (e.g. duplicate click) - idempotent no-op.
return;
}
final UserAgreementAcceptance acceptance = UserAgreementAcceptance.create(currentUser, active, ipAddress, userAgent);
this.userAgreementAcceptanceRepository.saveAndFlush(acceptance);
}
private void validate(final String title, final String body) {
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors).resource("useragreement");
baseDataValidator.reset().parameter(TITLE).value(title).notBlank().notExceedingLengthOf(200);
baseDataValidator.reset().parameter(BODY).value(body).notBlank();
if (!dataValidationErrors.isEmpty()) {
throw new PlatformApiDataValidationException(dataValidationErrors);
}
}
}
@@ -124,6 +124,10 @@ final class UsersApiResourceSwagger {
public String email;
@Schema(example = "9999999999")
public String mobileNo;
@Schema(description = "Mandatory. Channel used to enroll this user's OTP-based 2FA at creation time - a verified "
+ "m_user_mfa_factor row is created immediately, so the existing 2FA system challenges them for OTP on their very "
+ "first login. One of EMAIL, SMS. SMS requires mobileNo to be set.", example = "EMAIL")
public String otpDeliveryMethod;
@Schema(example = "1")
public Long officeId;
@Schema(example = "1")
@@ -205,6 +209,10 @@ final class UsersApiResourceSwagger {
public String email;
@Schema(example = "9999999999")
public String mobileNo;
@Schema(description = "Optional. One of EMAIL, SMS. Currently accepted for validation parity with user creation but not "
+ "otherwise used on update - changing an existing user's enrolled 2FA channel is a separate Access Control action.",
example = "EMAIL")
public String otpDeliveryMethod;
@Schema(example = "1")
public Long officeId;
@Schema(example = "1")
@@ -0,0 +1,46 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.useradministration.handler;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.useradministration.agreement.service.UserAgreementWritePlatformService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "USERAGREEMENT", action = "CREATE")
public class CreateUserAgreementCommandHandler implements NewCommandSourceHandler {
private final UserAgreementWritePlatformService writePlatformService;
@Autowired
public CreateUserAgreementCommandHandler(final UserAgreementWritePlatformService writePlatformService) {
this.writePlatformService = writePlatformService;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.create(command);
}
}
@@ -23,6 +23,8 @@ import static org.apache.fineract.useradministration.service.AppUserConstants.CL
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import jakarta.persistence.PersistenceException;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
@@ -59,6 +61,9 @@ import org.apache.fineract.useradministration.domain.UserDomainService;
import org.apache.fineract.useradministration.exception.PasswordPreviouslyUsedException;
import org.apache.fineract.useradministration.exception.RoleNotFoundException;
import org.apache.fineract.useradministration.exception.UserNotFoundException;
import org.apache.fineract.useradministration.mfa.domain.MfaFactorType;
import org.apache.fineract.useradministration.mfa.domain.UserMfaFactor;
import org.apache.fineract.useradministration.mfa.domain.UserMfaFactorRepository;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Caching;
import org.springframework.dao.DataIntegrityViolationException;
@@ -84,6 +89,7 @@ public class AppUserWritePlatformServiceJpaRepositoryImpl implements AppUserWrit
private final StaffRepositoryWrapper staffRepositoryWrapper;
private final ClientRepositoryWrapper clientRepositoryWrapper;
private final ConfigurationDomainService configurationDomainService;
private final UserMfaFactorRepository userMfaFactorRepository;
@Override
@Transactional
@@ -143,6 +149,24 @@ public class AppUserWritePlatformServiceJpaRepositoryImpl implements AppUserWrit
final Boolean sendPasswordToEmail = command.booleanObjectValueOfParameterNamed("sendPasswordToEmail");
this.userDomainService.create(appUser, sendPasswordToEmail);
// Admin picks the OTP delivery channel for this user at creation time (mandatory field
// in the Create User form). Enrolling it here as an already-verified MFA factor means the
// existing 2FA system (MfaFactorBackedTwoFactorServiceImpl + /twofactor + the
// TwoFactorAuthenticationComponent login dialog) challenges this user for OTP on their very
// first login, with no separate first-login flow needed - it's the same experience any
// MFA-enrolled user already gets.
final String otpDeliveryMethod = command.stringValueOfParameterNamed("otpDeliveryMethod");
if (otpDeliveryMethod != null) {
final MfaFactorType factorType = MfaFactorType.fromString(otpDeliveryMethod);
final UserMfaFactor factor = new UserMfaFactor();
factor.setAppUser(appUser);
factor.setFactorType(factorType);
factor.setDestination(factorType == MfaFactorType.SMS ? appUser.getMobileNo() : appUser.getEmail());
factor.setVerified(true);
factor.setVerifiedOn(OffsetDateTime.now(ZoneOffset.UTC));
this.userMfaFactorRepository.save(factor);
}
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(appUser.getId()) //
@@ -59,15 +59,18 @@ public final class UserDataValidator {
public static final String SEND_PASSWORD_TO_EMAIL = "sendPasswordToEmail";
public static final String STAFF_ID = "staffId";
public static final String PASSWORD_NEVER_EXPIRES = "passwordNeverExpires";
public static final String OTP_DELIVERY_METHOD = "otpDeliveryMethod";
/**
* The parameters supported for this command.
*/
private static final Set<String> CREATE_SUPPORTED_PARAMETERS = new HashSet<>(
Arrays.asList(USERNAME, FIRSTNAME, LASTNAME, PASSWORD, REPEAT_PASSWORD, EMAIL, MOBILE_NO, OFFICE_ID, NOT_SELECTED_ROLES,
ROLES, SEND_PASSWORD_TO_EMAIL, STAFF_ID, PASSWORD_NEVER_EXPIRES, AppUserConstants.IS_SELF_SERVICE_USER, CLIENTS));
ROLES, SEND_PASSWORD_TO_EMAIL, STAFF_ID, PASSWORD_NEVER_EXPIRES, AppUserConstants.IS_SELF_SERVICE_USER, CLIENTS,
OTP_DELIVERY_METHOD));
private static final Set<String> UPDATE_SUPPORTED_PARAMETERS = new HashSet<>(
Arrays.asList(USERNAME, FIRSTNAME, LASTNAME, PASSWORD, REPEAT_PASSWORD, EMAIL, MOBILE_NO, OFFICE_ID, NOT_SELECTED_ROLES,
ROLES, SEND_PASSWORD_TO_EMAIL, STAFF_ID, PASSWORD_NEVER_EXPIRES, AppUserConstants.IS_SELF_SERVICE_USER, CLIENTS));
ROLES, SEND_PASSWORD_TO_EMAIL, STAFF_ID, PASSWORD_NEVER_EXPIRES, AppUserConstants.IS_SELF_SERVICE_USER, CLIENTS,
OTP_DELIVERY_METHOD));
private static final Set<String> CHANGE_PASSWORD_SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList(PASSWORD, REPEAT_PASSWORD));
public static final String PASSWORD_NEVER_EXPIRE = "passwordNeverExpire";
@@ -120,6 +123,14 @@ public final class UserDataValidator {
final Long officeId = this.fromApiJsonHelper.extractLongNamed(OFFICE_ID, element);
baseDataValidator.reset().parameter(OFFICE_ID).value(officeId).notNull().integerGreaterThanZero();
final String otpDeliveryMethod = this.fromApiJsonHelper.extractStringNamed(OTP_DELIVERY_METHOD, element);
baseDataValidator.reset().parameter(OTP_DELIVERY_METHOD).value(otpDeliveryMethod).notBlank()
.isOneOfTheseStringValues(Arrays.asList("EMAIL", "SMS"));
if ("SMS".equalsIgnoreCase(otpDeliveryMethod)) {
final String mobileNo = this.fromApiJsonHelper.extractStringNamed(MOBILE_NO, element);
baseDataValidator.reset().parameter(MOBILE_NO).value(mobileNo).notBlank();
}
if (this.fromApiJsonHelper.parameterExists(STAFF_ID, element)) {
final Long staffId = this.fromApiJsonHelper.extractLongNamed(STAFF_ID, element);
baseDataValidator.reset().parameter(STAFF_ID).value(staffId).notNull().integerGreaterThanZero();
@@ -258,6 +269,12 @@ public final class UserDataValidator {
baseDataValidator.reset().parameter(EMAIL).value(email).notBlank().notExceedingLengthOf(100);
}
if (this.fromApiJsonHelper.parameterExists(OTP_DELIVERY_METHOD, element)) {
final String otpDeliveryMethod = this.fromApiJsonHelper.extractStringNamed(OTP_DELIVERY_METHOD, element);
baseDataValidator.reset().parameter(OTP_DELIVERY_METHOD).value(otpDeliveryMethod).notBlank()
.isOneOfTheseStringValues(Arrays.asList("EMAIL", "SMS"));
}
if (this.fromApiJsonHelper.parameterExists(ROLES, element)) {
final String[] roles = this.fromApiJsonHelper.extractArrayNamed(ROLES, element);
baseDataValidator.reset().parameter(ROLES).value(roles).arrayNotEmpty();
@@ -52,6 +52,13 @@ import org.apache.fineract.useradministration.service.RoleReadPlatformService;
import org.apache.fineract.useradministration.service.RoleReadPlatformServiceImpl;
import org.apache.fineract.useradministration.service.RoleWritePlatformService;
import org.apache.fineract.useradministration.service.RoleWritePlatformServiceJpaRepositoryImpl;
import org.apache.fineract.useradministration.agreement.domain.UserAgreementAcceptanceRepository;
import org.apache.fineract.useradministration.agreement.domain.UserAgreementRepository;
import org.apache.fineract.useradministration.agreement.service.UserAgreementReadPlatformService;
import org.apache.fineract.useradministration.agreement.service.UserAgreementReadPlatformServiceImpl;
import org.apache.fineract.useradministration.agreement.service.UserAgreementWritePlatformService;
import org.apache.fineract.useradministration.agreement.service.UserAgreementWritePlatformServiceImpl;
import org.apache.fineract.useradministration.mfa.domain.UserMfaFactorRepository;
import org.apache.fineract.useradministration.service.UserDataValidator;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
@@ -76,10 +83,11 @@ public class UserAdministrationConfiguration {
PlatformPasswordEncoder platformPasswordEncoder, AppUserRepository appUserRepository,
OfficeRepositoryWrapper officeRepositoryWrapper, RoleRepository roleRepository, UserDataValidator fromApiJsonDeserializer,
AppUserPreviousPasswordRepository appUserPreviewPasswordRepository, StaffRepositoryWrapper staffRepositoryWrapper,
ClientRepositoryWrapper clientRepositoryWrapper, ConfigurationDomainService configurationDomainService) {
ClientRepositoryWrapper clientRepositoryWrapper, ConfigurationDomainService configurationDomainService,
UserMfaFactorRepository userMfaFactorRepository) {
return new AppUserWritePlatformServiceJpaRepositoryImpl(context, userDomainService, platformPasswordEncoder, appUserRepository,
officeRepositoryWrapper, roleRepository, fromApiJsonDeserializer, appUserPreviewPasswordRepository, staffRepositoryWrapper,
clientRepositoryWrapper, configurationDomainService);
clientRepositoryWrapper, configurationDomainService, userMfaFactorRepository);
}
@Bean
@@ -124,4 +132,17 @@ public class UserAdministrationConfiguration {
return new RoleWritePlatformServiceJpaRepositoryImpl(context, roleRepository, permissionRepository,
roleCommandFromApiJsonDeserializer, permissionsFromApiJsonDeserializer);
}
@Bean
@ConditionalOnMissingBean(UserAgreementReadPlatformService.class)
public UserAgreementReadPlatformService userAgreementReadPlatformService(UserAgreementRepository userAgreementRepository) {
return new UserAgreementReadPlatformServiceImpl(userAgreementRepository);
}
@Bean
@ConditionalOnMissingBean(UserAgreementWritePlatformService.class)
public UserAgreementWritePlatformService userAgreementWritePlatformService(PlatformSecurityContext context,
UserAgreementRepository userAgreementRepository, UserAgreementAcceptanceRepository userAgreementAcceptanceRepository) {
return new UserAgreementWritePlatformServiceImpl(context, userAgreementRepository, userAgreementAcceptanceRepository);
}
}
@@ -435,6 +435,11 @@
<include file="parts/0412_activate_outward_clearing_batch_job.xml" relativeToChangelogFile="true"/>
<include file="parts/0413_inward_item_verify_signature_permission.xml" relativeToChangelogFile="true"/>
<include file="parts/0414_inward_item_full_source_details.xml" relativeToChangelogFile="true"/>
<include file="parts/0415_add_otp_delivery_method_and_first_login_gate_to_appuser.xml" relativeToChangelogFile="true"/>
<include file="parts/0416_create_user_agreement_tables.xml" relativeToChangelogFile="true"/>
<include file="parts/0417_add_first_login_gate_config.xml" relativeToChangelogFile="true"/>
<include file="parts/0418_add_user_agreement_permissions.xml" relativeToChangelogFile="true"/>
<include file="parts/0419_revert_first_login_gate_column_and_config.xml" relativeToChangelogFile="true"/>
<!-- NOTE: the numbers below (0341-0359) re-use prefixes already used above by the chequebook/CTS
clearing catchup migrations — these come from a long-diverged branch (uate) that assigned its own
sequence independently. Liquibase tracks changesets by filename + id + author, not by the numeric
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.3.xsd">
<changeSet author="fineract" id="1">
<addColumn tableName="m_appuser">
<column name="otp_delivery_method" type="varchar(10)">
<constraints nullable="true"/>
</column>
<column name="first_login_gate_pending" type="boolean" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,106 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!--
Versioned admin-manageable "user agreement" text (bank policy/warning) that newly-created users
must accept during the first-login gate. Only one row may be active at a time - enforced in the
write service (UserAgreementWritePlatformServiceImpl), not a DB constraint, since flipping "active"
is a two-row transaction (deactivate old, activate new).
-->
<changeSet id="0416_create_m_user_agreement" author="fineract">
<preConditions onFail="MARK_RAN"><not><tableExists tableName="m_user_agreement"/></not></preConditions>
<createTable tableName="m_user_agreement">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints nullable="false" primaryKey="true"/>
</column>
<column name="version" type="INT">
<constraints nullable="false"/>
</column>
<column name="title" type="VARCHAR(200)">
<constraints nullable="false"/>
</column>
<column name="body" type="TEXT">
<constraints nullable="false"/>
</column>
<column name="is_active" type="boolean" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
<column name="created_by" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="created_on" type="datetime" defaultValueComputed="CURRENT_TIMESTAMP">
<constraints nullable="false"/>
</column>
</createTable>
<addUniqueConstraint tableName="m_user_agreement" columnNames="version"
constraintName="uq_user_agreement_version"/>
<addForeignKeyConstraint baseTableName="m_user_agreement" baseColumnNames="created_by"
constraintName="fk_user_agreement_created_by"
referencedTableName="m_appuser" referencedColumnNames="id"/>
</changeSet>
<!--
Audit-proof record of a user accepting a given version of the agreement during the first-login gate.
agreement_version is a denormalized snapshot of m_user_agreement.version at acceptance time, so the
acceptance record survives future version bumps unaffected.
-->
<changeSet id="0416_create_m_user_agreement_acceptance" author="fineract">
<preConditions onFail="MARK_RAN"><not><tableExists tableName="m_user_agreement_acceptance"/></not></preConditions>
<createTable tableName="m_user_agreement_acceptance">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints nullable="false" primaryKey="true"/>
</column>
<column name="appuser_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="agreement_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="agreement_version" type="INT">
<constraints nullable="false"/>
</column>
<column name="accepted_on" type="datetime" defaultValueComputed="CURRENT_TIMESTAMP">
<constraints nullable="false"/>
</column>
<column name="ip_address" type="VARCHAR(45)"/>
<column name="user_agent" type="VARCHAR(500)"/>
</createTable>
<addUniqueConstraint tableName="m_user_agreement_acceptance" columnNames="appuser_id, agreement_id"
constraintName="uq_user_agreement_acceptance"/>
<addForeignKeyConstraint baseTableName="m_user_agreement_acceptance" baseColumnNames="appuser_id"
constraintName="fk_user_agreement_acceptance_user"
referencedTableName="m_appuser" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_user_agreement_acceptance" baseColumnNames="agreement_id"
constraintName="fk_user_agreement_acceptance_agreement"
referencedTableName="m_user_agreement" referencedColumnNames="id"/>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.3.xsd">
<changeSet author="fineract" id="1">
<insert tableName="c_configuration">
<column name="name" value="enable-first-login-otp-and-agreement-verification"/>
<column name="value" valueNumeric="0"/>
<column name="enabled" valueBoolean="false"/>
<column name="is_trap_door" valueBoolean="false"/>
<column name="description" value="If enabled, newly created users must verify OTP via their chosen channel and accept the active user agreement on first login before access is granted. Verification also auto-enrolls that channel as ongoing MFA. Existing users are unaffected. Value unused."/>
</insert>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!--
Permissions for the admin "user agreement" management API (/v1/useragreements), used to
publish the versioned agreement text every user must accept right after logging in.
-->
<changeSet id="0418-user-agreement-create-permission" author="CBS" runOnChange="false">
<sql>
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'organisation', 'CREATE_USERAGREEMENT', 'USERAGREEMENT', 'CREATE', false
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'CREATE_USERAGREEMENT');
</sql>
</changeSet>
<changeSet id="0418-user-agreement-read-permission" author="CBS" runOnChange="false">
<sql>
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'organisation', 'READ_USERAGREEMENT', 'USERAGREEMENT', 'READ', false
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'READ_USERAGREEMENT');
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!--
Reverts 0415/0417: the first-login "gate" (its own status/complete API, pending-flag, and
dedicated toggle) was replaced by simply auto-enrolling the admin-chosen OTP channel as an
already-verified m_user_mfa_factor row at user-creation time - the existing 2FA system
(MfaFactorBackedTwoFactorServiceImpl + /twofactor + TwoFactorAuthenticationComponent) then
challenges the user for OTP on their very first login with no new code at all. Neither
column nor the config toggle are read by any code anymore.
-->
<changeSet id="0419-drop-otp-delivery-method-column" author="CBS">
<preConditions onFail="MARK_RAN">
<columnExists tableName="m_appuser" columnName="otp_delivery_method"/>
</preConditions>
<dropColumn tableName="m_appuser" columnName="otp_delivery_method"/>
</changeSet>
<changeSet id="0419-drop-first-login-gate-pending-column" author="CBS">
<preConditions onFail="MARK_RAN">
<columnExists tableName="m_appuser" columnName="first_login_gate_pending"/>
</preConditions>
<dropColumn tableName="m_appuser" columnName="first_login_gate_pending"/>
</changeSet>
<changeSet id="0419-remove-first-login-gate-config" author="CBS">
<sql>
DELETE FROM c_configuration WHERE name = 'enable-first-login-otp-and-agreement-verification';
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,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;
}
}