Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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; }
}
Original file line number Diff line number Diff line change
@@ -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<OTPStore, Long> {

// Find OTP by username
Optional<OTPStore> 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);
}
Original file line number Diff line number Diff line change
@@ -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<OTPStore> 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<OTPStore> getLatestOTP(String username) {
return otpStoreRepository.findByUsername(username);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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;
Expand All @@ -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<String, OtpEntry> otpCache = new ConcurrentHashMap<>();
private final OTPService otpService;
private static final ExecutorService executorService = Executors.newFixedThreadPool(1);


Expand Down Expand Up @@ -387,10 +389,8 @@ public Response requestForgotPasswordOtp(Map<String, String> 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"
Expand All @@ -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 })
Expand All @@ -433,22 +432,10 @@ public Response verifyOtpAndResetPassword(Map<String, String> 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
Expand All @@ -467,26 +454,12 @@ public Response verifyOtpAndResetPassword(Map<String, String> 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<String,OtpEntry> otp :otpCache.entrySet()) {
OtpEntry otpEntry = otp.getValue();
String username= otp.getKey();
if (Duration.between(otpEntry.getGeneratedAt(), Instant.now()).toMinutes() > 10) {
otpCache.remove(username);
}
}
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -153,4 +153,5 @@
<include file="parts/0131_create_user_session.xml" relativeToChangelogFile="true" />
<include file="parts/0133_alter_appuser_add_bad_cred_count.xml" relativeToChangelogFile="true" />
<include file="parts/0134_add_credentials_locked_at_column.xml" relativeToChangelogFile="true"/>
<include file="parts/0135_create_otp_store_table.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?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.4.xsd">

<changeSet id="0135" author="pareekshith">
<createTable tableName="m_otp_store">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="username" type="VARCHAR(100)">
<constraints nullable="false"/>
</column>
<column name="otp_code" type="VARCHAR(10)">
<constraints nullable="false"/>
</column>
<column name="generated_at" type="DATETIME">
<constraints nullable="false"/>
</column>
<column name="expiry_time" type="DATETIME">
<constraints nullable="false"/>
</column>
</createTable>

<createIndex indexName="idx_m_otp_store_username" tableName="m_otp_store">
<column name="username"/>
</createIndex>
</changeSet>
</databaseChangeLog>
Loading