diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java index 685cacb8c..5e2c31402 100644 --- a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java +++ b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java @@ -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"; diff --git a/fineract-core/src/main/java/org/apache/fineract/useradministration/agreement/domain/UserAgreement.java b/fineract-core/src/main/java/org/apache/fineract/useradministration/agreement/domain/UserAgreement.java new file mode 100644 index 000000000..fe41f9ec6 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/useradministration/agreement/domain/UserAgreement.java @@ -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 { + + @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; + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/useradministration/agreement/domain/UserAgreementAcceptance.java b/fineract-core/src/main/java/org/apache/fineract/useradministration/agreement/domain/UserAgreementAcceptance.java new file mode 100644 index 000000000..1c0934bb4 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/useradministration/agreement/domain/UserAgreementAcceptance.java @@ -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 { + + @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; + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/useradministration/agreement/domain/UserAgreementAcceptanceRepository.java b/fineract-core/src/main/java/org/apache/fineract/useradministration/agreement/domain/UserAgreementAcceptanceRepository.java new file mode 100644 index 000000000..343547d3e --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/useradministration/agreement/domain/UserAgreementAcceptanceRepository.java @@ -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 { + + boolean existsByAppUser_IdAndAgreement_Id(Long appUserId, Long agreementId); +} diff --git a/fineract-core/src/main/java/org/apache/fineract/useradministration/agreement/domain/UserAgreementRepository.java b/fineract-core/src/main/java/org/apache/fineract/useradministration/agreement/domain/UserAgreementRepository.java new file mode 100644 index 000000000..54dc13431 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/useradministration/agreement/domain/UserAgreementRepository.java @@ -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 { + + Optional findByActiveTrue(); + + List findAllByOrderByVersionDesc(); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/service/EmailLoginOtpServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/service/EmailLoginOtpServiceImpl.java index 2f41ee110..80969e691 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/service/EmailLoginOtpServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/service/EmailLoginOtpServiceImpl.java @@ -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(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/service/LoginOtpServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/service/LoginOtpServiceImpl.java index 754cee034..61e7a0d05 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/service/LoginOtpServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/otp/service/LoginOtpServiceImpl.java @@ -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; diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/agreement/api/UserAgreementApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/agreement/api/UserAgreementApiResource.java new file mode 100644 index 000000000..832861838 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/agreement/api/UserAgreementApiResource.java @@ -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 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 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(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/agreement/data/UserAgreementData.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/agreement/data/UserAgreementData.java new file mode 100644 index 000000000..b48e92054 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/agreement/data/UserAgreementData.java @@ -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; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/agreement/exception/UserAgreementNotFoundException.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/agreement/exception/UserAgreementNotFoundException.java new file mode 100644 index 000000000..561d67f6f --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/agreement/exception/UserAgreementNotFoundException.java @@ -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."); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/agreement/service/UserAgreementReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/agreement/service/UserAgreementReadPlatformService.java new file mode 100644 index 000000000..3111b59e9 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/agreement/service/UserAgreementReadPlatformService.java @@ -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 retrieveAll(); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/agreement/service/UserAgreementReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/agreement/service/UserAgreementReadPlatformServiceImpl.java new file mode 100644 index 000000000..1444d1980 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/agreement/service/UserAgreementReadPlatformServiceImpl.java @@ -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 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()); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/agreement/service/UserAgreementWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/agreement/service/UserAgreementWritePlatformService.java new file mode 100644 index 000000000..a63ad012e --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/agreement/service/UserAgreementWritePlatformService.java @@ -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); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/agreement/service/UserAgreementWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/agreement/service/UserAgreementWritePlatformServiceImpl.java new file mode 100644 index 000000000..3de25c4f1 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/agreement/service/UserAgreementWritePlatformServiceImpl.java @@ -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 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); + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/api/UsersApiResourceSwagger.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/api/UsersApiResourceSwagger.java index 03f7428d4..de2e9e338 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/useradministration/api/UsersApiResourceSwagger.java +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/api/UsersApiResourceSwagger.java @@ -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") diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/handler/CreateUserAgreementCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/handler/CreateUserAgreementCommandHandler.java new file mode 100644 index 000000000..542d29e22 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/handler/CreateUserAgreementCommandHandler.java @@ -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); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/AppUserWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/AppUserWritePlatformServiceJpaRepositoryImpl.java index 0ba57146f..7ab6a9866 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/AppUserWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/AppUserWritePlatformServiceJpaRepositoryImpl.java @@ -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()) // diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserDataValidator.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserDataValidator.java index 2f87652de..762562c85 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserDataValidator.java +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/service/UserDataValidator.java @@ -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 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 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 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(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/starter/UserAdministrationConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/starter/UserAdministrationConfiguration.java index 9ef25cac8..ae7e5ec9e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/useradministration/starter/UserAdministrationConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/starter/UserAdministrationConfiguration.java @@ -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); + } } diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index cd113a4f9..b12a3bf79 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -435,6 +435,11 @@ + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0416_create_user_agreement_tables.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0416_create_user_agreement_tables.xml new file mode 100644 index 000000000..3408c9384 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0416_create_user_agreement_tables.xml @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0417_add_first_login_gate_config.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0417_add_first_login_gate_config.xml new file mode 100644 index 000000000..8338de2bb --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0417_add_first_login_gate_config.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0418_add_user_agreement_permissions.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0418_add_user_agreement_permissions.xml new file mode 100644 index 000000000..67efb4d4c --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0418_add_user_agreement_permissions.xml @@ -0,0 +1,27 @@ + + + + + + + + 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'); + + + + + + 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'); + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0419_revert_first_login_gate_column_and_config.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0419_revert_first_login_gate_column_and_config.xml new file mode 100644 index 000000000..b01145c59 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0419_revert_first_login_gate_column_and_config.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + DELETE FROM c_configuration WHERE name = 'enable-first-login-otp-and-agreement-verification'; + + + + diff --git a/fineract-security/src/main/java/org/apache/fineract/infrastructure/security/otp/util/OtpDestinationMasker.java b/fineract-security/src/main/java/org/apache/fineract/infrastructure/security/otp/util/OtpDestinationMasker.java new file mode 100644 index 000000000..42b6eb800 --- /dev/null +++ b/fineract-security/src/main/java/org/apache/fineract/infrastructure/security/otp/util/OtpDestinationMasker.java @@ -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. + * + *

+ * 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; + } +}