diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/api/AuthenticationApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/api/AuthenticationApiResource.java index 19b5b305d28..57a9dc0563a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/api/AuthenticationApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/api/AuthenticationApiResource.java @@ -136,7 +136,17 @@ public String authenticate(@Parameter(hidden = true) final String apiRequestBody appUser.setCredentialsLockedAt(null); this.springSecurityPlatformSecurityContext.saveAppUser(appUser); } else { - throw new IllegalArgumentException("Account is temporarily locked. Try again after 10 minutes."); + //throw new IllegalArgumentException("Account is temporarily locked. Try again after 10 minutes."); + long secondsLeft = java.time.Duration.between(LocalDateTime.now(), unlockTime).getSeconds(); + long minutesLeft = secondsLeft / 60; + long remainingSeconds = secondsLeft % 60; + + String message = String.format( + "Account is temporarily locked for 5 minutes for security. Please try again in %d minutes %d seconds.", + minutesLeft, remainingSeconds + ); + + throw validationError("error.msg.account.locked", message); } } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/domain/OTPStore.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/domain/OTPStore.java new file mode 100644 index 00000000000..256efade63c --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/domain/OTPStore.java @@ -0,0 +1,73 @@ +/** + * 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.domain; + +import jakarta.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "m_otp_store", indexes = { + @Index(name = "idx_m_otp_store_username", columnList = "username") +}) +public class OTPStore { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "username", nullable = false, length = 100) + private String username; + + @Column(name = "otp_code", nullable = false, length = 10) + private String otpCode; + + @Column(name = "generated_at", nullable = false) + private LocalDateTime generatedAt; + + @Column(name = "expiry_time", nullable = false) + private LocalDateTime expiryTime; + + // 🔹 Default constructor (required by JPA) + public OTPStore() {} + + // 🔹 Convenience constructor + public OTPStore(String username, String otpCode, LocalDateTime generatedAt, LocalDateTime expiryTime) { + this.username = username; + this.otpCode = otpCode; + this.generatedAt = generatedAt; + this.expiryTime = expiryTime; + } + + // 🔹 Getters and Setters + public Long getId() { return id; } + + public String getUsername() { return username; } + public void setUsername(String username) { this.username = username; } + + public String getOtpCode() { return otpCode; } + public void setOtpCode(String otpCode) { this.otpCode = otpCode; } + + public LocalDateTime getGeneratedAt() { return generatedAt; } + public void setGeneratedAt(LocalDateTime generatedAt) { this.generatedAt = generatedAt; } + + public LocalDateTime getExpiryTime() { return expiryTime; } + public void setExpiryTime(LocalDateTime expiryTime) { this.expiryTime = expiryTime; } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/domain/OTPStoreRepository.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/domain/OTPStoreRepository.java new file mode 100644 index 00000000000..c375bed2eee --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/domain/OTPStoreRepository.java @@ -0,0 +1,40 @@ +/** + * 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.domain; + +import java.time.LocalDateTime; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface OTPStoreRepository extends JpaRepository { + + // Find OTP by username + Optional findByUsername(String username); + + // Delete OTP by username (after success or manual cleanup) + void deleteByUsername(String username); + + // Delete all expired OTPs (e.g. cleanup job) + void deleteAllByExpiryTimeBefore(LocalDateTime now); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/service/OTPService.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/service/OTPService.java new file mode 100644 index 00000000000..48a9a95b189 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/service/OTPService.java @@ -0,0 +1,112 @@ +/** + * 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.service; + +import jakarta.transaction.Transactional; +import org.apache.fineract.infrastructure.security.domain.OTPStore; +import org.apache.fineract.infrastructure.security.domain.OTPStoreRepository; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.Random; + +@Service +public class OTPService { + + private final OTPStoreRepository otpStoreRepository; + private final Random random = new Random(); + + public OTPService(OTPStoreRepository otpStoreRepository) { + this.otpStoreRepository = otpStoreRepository; + } + + /** + * Generates a 6-digit OTP, stores it in the DB with expiry time, and returns the OTP. + * @param username the user for which OTP is generated + * @return the generated OTP + */ + @Transactional + public String generateOTP(String username) { + + // cleanup existing OTPs for the same user + otpStoreRepository.deleteByUsername(username); + + // Generate 6-digit OTP + String otp = String.format("%06d", random.nextInt(900000) + 100000); + + // Set generated and expiry times + LocalDateTime now = LocalDateTime.now(); + LocalDateTime expiryTime = now.plusMinutes(10); // OTP valid for 10 minutes + + // Save to DB + OTPStore otpStore = new OTPStore(username, otp, now, expiryTime); + otpStoreRepository.save(otpStore); + + // Cleanup expired OTPs + deleteExpiredOTPs(); + + return otp; + } + + /** + * Verifies the OTP for a given username. + * @param username the user + * @param otp the OTP to verify + * @return true if valid, false otherwise + */ + @Transactional + public boolean verifyOTP(String username, String otp) { + Optional optionalOTP = otpStoreRepository.findByUsername(username); + + if (optionalOTP.isEmpty()) return false; + + OTPStore otpStore = optionalOTP.get(); + + // Check if expired + if (otpStore.getExpiryTime().isBefore(LocalDateTime.now())) { + otpStoreRepository.delete(otpStore); + return false; + } + + boolean valid = otpStore.getOtpCode().equals(otp); + + // Delete OTP after verification regardless of result + otpStoreRepository.delete(otpStore); + + return valid; + } + + /** + * Deletes all expired OTPs from the database. + */ + @Transactional + public void deleteExpiredOTPs() { + otpStoreRepository.deleteAllByExpiryTimeBefore(LocalDateTime.now()); + } + + /** + * Optional helper to fetch the latest OTP (useful for testing) + */ + public Optional getLatestOTP(String username) { + return otpStoreRepository.findByUsername(username); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/useradministration/api/UsersApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/useradministration/api/UsersApiResource.java index 85920cf02bf..d044c0bf468 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/useradministration/api/UsersApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/useradministration/api/UsersApiResource.java @@ -67,6 +67,7 @@ import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer; import org.apache.fineract.infrastructure.core.service.PlatformEmailService; import org.apache.fineract.infrastructure.security.api.AuthenticationApiResource; +import org.apache.fineract.infrastructure.security.service.OTPService; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.organisation.office.data.OfficeData; import org.apache.fineract.organisation.office.service.OfficeReadPlatformService; @@ -87,6 +88,8 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Component; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; @Path("/v1/users") @Component @@ -101,6 +104,7 @@ public class UsersApiResource { "firstname", "lastname", "email", "allowedOffices", "availableRoles", "selectedRoles", "staff")); private static final String RESOURCE_NAME_FOR_PERMISSIONS = "USER"; + private static final Logger LOG = LoggerFactory.getLogger(UsersApiResource.class); private final PlatformSecurityContext context; private final AppUserReadPlatformService readPlatformService; @@ -114,9 +118,7 @@ public class UsersApiResource { private final PasswordEncoder passwordEncoder; private final AppUserRepository appUserRepository; private final PlatformEmailService emailService; - //for testing purposes only - // Temporary in-memory OTP cache (for testing only): move this to redis or DB - private final static Map otpCache = new ConcurrentHashMap<>(); + private final OTPService otpService; private static final ExecutorService executorService = Executors.newFixedThreadPool(1); @@ -387,10 +389,8 @@ public Response requestForgotPasswordOtp(Map request) { return Response.status(Response.Status.BAD_REQUEST).entity(Map.of("error", "Email does not match")).build(); } - // Generate OTP and store it temporarily (for testing, use static map) - String otp = String.valueOf((int)(100000 + Math.random() * 900000)); // 6-digit OTP - otpCache.put(username, new OtpEntry(otp, Instant.now())); - cleanUpExpiredOTPs(); + // Generate OTP using OTPService + String otp = otpService.generateOTP(username); sendOtpInEmail(email, otp); return Response.ok(Map.of( "message", "OTP generated successfully and sent to registered email" @@ -407,7 +407,6 @@ private void sendOtpInEmail(String email, String otp) { emailService.sendEmailWIthTemplates(templateNameSub, templateNameBody, reqMap); } - @POST @Path("forgot-password/verify") @Consumes({ MediaType.APPLICATION_JSON }) @@ -433,22 +432,10 @@ public Response verifyOtpAndResetPassword(Map request) { AppUser user = optionalUser.get(); // Check OTP - OtpEntry otpEntry = otpCache.get(username); - if (otpEntry == null) { - return Response.status(Response.Status.BAD_REQUEST) - .entity(Map.of("error", "No OTP request found for this user")).build(); - } - - if (!otpEntry.getOtp().equals(otp)) { - return Response.status(Response.Status.UNAUTHORIZED) - .entity(Map.of("error", "Invalid OTP")).build(); - } - - // Check if OTP is expired (valid for 10 minutes) - if (Duration.between(otpEntry.getGeneratedAt(), Instant.now()).toMinutes() > 10) { - otpCache.remove(username); + boolean isValidOtp = otpService.verifyOTP(username, otp); + if (!isValidOtp) { return Response.status(Response.Status.UNAUTHORIZED) - .entity(Map.of("error", "OTP expired")).build(); + .entity(Map.of("error", "Invalid or expired OTP")).build(); } // Check if passwords match @@ -467,26 +454,12 @@ public Response verifyOtpAndResetPassword(Map request) { user.setFailedLoginAttempts(0); appUserRepository.save(user); - otpCache.remove(username); // Log to console - System.out.println(" Password reset successful for user: " + username); - System.out.println(" Account temporarily locked for 5 minutes after password reset."); - cleanUpExpiredOTPs(); + LOG.info("Password reset successful for user: {}", username); + LOG.info("Account temporarily locked for 5 minutes after password reset."); return Response.ok(Map.of( "message", "Password reset successful. Account is temporarily locked for 5 minutes for security." )).build(); } - - private void cleanUpExpiredOTPs(){ - executorService.submit(() ->{ - for(Map.Entry otp :otpCache.entrySet()) { - OtpEntry otpEntry = otp.getValue(); - String username= otp.getKey(); - if (Duration.between(otpEntry.getGeneratedAt(), Instant.now()).toMinutes() > 10) { - otpCache.remove(username); - } - } - }); - } } 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 73131a8c18e..66a33ab71d0 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 @@ -153,4 +153,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0135_create_otp_store_table.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0135_create_otp_store_table.xml new file mode 100644 index 00000000000..7c0f59aa286 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0135_create_otp_store_table.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + +