Skip to content

Conversation

@airajena
Copy link
Contributor

Description

Implemented the "Forgot Password" functionality to allow users to reset their forgotten passwords via email. This feature introduces a new public API endpoint that verifies the user's email, generates a temporary password, and emails it to them.

Changes

  • New API Endpoint: Added POST /api/v1/password/forgot which accepts an email address in the request body.
    • Updated SecurityConfig to permit unauthenticated access to this endpoint.
  • Database Schema: Added temporary_password_expiry_time column to the m_appuser table (via Liquibase migration 0209_add_forgot_password.xml).
  • Domain Logic:
    • Updated AppUser entity to handle temporary password expiry.
    • Added AppUserRepository.findActiveUserByEmail to lookup users.
  • Service Layer:
    • Created ForgotPasswordService and its implementation ForgotPasswordServiceImpl.
    • Logic handles finding the user, generating a 13-character random password, encrypting it, setting the expiry time (24 hours), and triggering the email.
  • Email Service Improvements: Updated GmailBackedPlatformEmailService to make strict SSL/TLS settings conditional. This allows the service to support standard SMTP servers (like Mailhog) for easier local testing and development, while still enforcing strict security when connecting to Gmail.

Checklist

Please confirm these details:

  • Catch up with develop branch
  • Format the code (./gradlew spotlessApply)
  • Staging/Production Smoke Tests

Testing

  • Tested locally using Docker Compose and Mailhog.
  • Verified the API returns 200 OK on success.
  • Verified database updates (temp password expiry time set).
  • Verified email usage logic.
  • Verified transaction rollback if email sending fails.

public void sendForgotPasswordEmail(String organisationName, String contactName, String address, String username,
String temporaryPassword) {
final String subject = "Password Reset Request - " + organisationName;
final String body = "Dear " + contactName + ",\n\n" + "You have requested to reset your password for your account on "
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can use textblock instead as it was introduced in 15+ version

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried using text blocks initially, but SpotBugs throws a VA_FORMAT_STRING_USES_NEWLINE error when using .formatted() with text blocks containing literal newlines. I've kept the string concatenation approach to match the existing
sendToUserAccount method pattern in the same file, which avoids the SpotBugs issue while maintaining consistency.

@Path("/forgot")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Request password reset", description = "Requests a password reset for the user with the given email. "
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use TextBlock

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done! Updated the @operation description to use a text block for better readability.

+ "If the email exists and the user is active, a temporary password will be sent to the email address. "
+ "The temporary password expires in 24 hours.")
@RequestBody(required = true, content = @Content(schema = @Schema(implementation = ForgotPasswordRequest.class)))
@ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") })
Copy link
Contributor

@Aman-Mittal Aman-Mittal Jan 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no need of @ApiResponses as we can use multiple @ ApiResponse(

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes I've removed @ApiResponses wrapper and using @apiresponse directly

props.put("mail.smtp.socketFactory.class", "javax.net.ssl.SSLSocketFactory");// NOSONAR
props.put("mail.smtp.socketFactory.fallback", "true");
// Only apply strict Gmail settings if we are actually connecting to Gmail
if (smtpCredentialsData.getHost() != null && smtpCredentialsData.getHost().endsWith("gmail.com")) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if user has email of other network provider what will happen? eg proton mail, yahoo , outlook?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code already handles this - it only applies strict SSL/TLS settings when the SMTP host ends with gmail.com. For other providers (Yahoo, Outlook, ProtonMail, or even local testing with Mailhog), it uses relaxed settings without forcing the SSL SocketFactory. This way, the email service works with any SMTP provider configured in the external services settings.

+ "The temporary password expires in 24 hours.")
@RequestBody(required = true, content = @Content(schema = @Schema(implementation = ForgotPasswordRequest.class)))
@ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") })
public Response forgotPassword(final ForgotPasswordRequest request) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to crosscheck if there is any rate-limit or any safeguards against abuse

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The endpoint has several safeguards in many places but rate limiting is not implemnted.

@airajena airajena force-pushed the FINERACT-2006/forgot-password branch from d548441 to aa3b4a0 Compare January 24, 2026 11:31
@Aman-Mittal
Copy link
Contributor

Aman-Mittal commented Jan 24, 2026 via email

@airajena
Copy link
Contributor Author

Brother, by your logic if it's for strict gmail. Then what about cases which uses their custom domain for example organization email or education email from Google workspace. What would happen to that? Will that work?

On Sat, 24 Jan, 2026, 5:05 pm Aira Jena, @.> wrote: @.* commented on this pull request. ------------------------------ In fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/service/GmailBackedPlatformEmailService.java <#5369 (comment)>: > @@ -74,12 +74,16 @@ public void sendDefinedEmail(EmailDetail emailDetails) { props.put("mail.debug", "true"); // these are the added lines - props.put("mail.smtp.starttls.enable", "true"); - // props.put("mail.smtp.ssl.enable", "true"); - - props.put("mail.smtp.socketFactory.port", Integer.parseInt(smtpCredentialsData.getPort())); - props.put("mail.smtp.socketFactory.class", "javax.net.ssl.SSLSocketFactory");// NOSONAR - props.put("mail.smtp.socketFactory.fallback", "true"); + // Only apply strict Gmail settings if we are actually connecting to Gmail + if (smtpCredentialsData.getHost() != null && smtpCredentialsData.getHost().endsWith("gmail.com")) { The code already handles this - it only applies strict SSL/TLS settings when the SMTP host ends with gmail.com. For other providers (Yahoo, Outlook, ProtonMail, or even local testing with Mailhog), it uses relaxed settings without forcing the SSL SocketFactory. This way, the email service works with any SMTP provider configured in the external services settings. — Reply to this email directly, view it on GitHub <#5369 (comment)>, or unsubscribe https://github.com/notifications/unsubscribe-auth/AHV6TA63OV2P7O3AEJXFMS34INKIHAVCNFSM6AAAAACSUFDBTOVHI2DSMVQWIX3LMV43YUDVNRWFEZLROVSXG5CSMV3GSZLXHMZTOMBRGQ3TKMRRG4 . You are receiving this because you commented.Message ID: @.***>

The check is based on the SMTP host (smtpCredentialsData.getHost()), not the user's email domain.
For Google Workspace with custom domains (e.g., user@company.org or user@university.edu), the SMTP server is still smtp.gmail.com. So the condition getHost().endsWith("gmail.com") would correctly apply the Gmail-specific SSL settings.

Examples:
Gmail (user@gmail.com) → SMTP host: smtp.gmail.com → Gmail settings applied
Google Workspace (user@company.org) → SMTP host: smtp.gmail.com → Gmail settings applied
Outlook 365 (user@company.org) → SMTP host: smtp.office365.com → elaxed settings
Yahoo → SMTP host: smtp.mail.yahoo.com → Relaxed settings
The SMTP host is configured in Fineract's external services settings by the administrator, so it correctly identifies the mail provider regardless of the sender's email domain.

@Aman-Mittal
Copy link
Contributor

Aman-Mittal commented Jan 24, 2026 via email

@airajena
Copy link
Contributor Author

I'm not sure about this, simply hard coding this logic is not the correct way to do it. What if google adds new SMTP hostname, de we have to maintain this setting everytime A new provider gets add. I think it should handled based on configuration based. This logic seems too fragile for me. Maybe I am wrong but way it is implemented in not right, these type of settings should be configuration driven.

On Sat, 24 Jan, 2026, 6:58 pm Aira Jena, @.> wrote: airajena left a comment (apache/fineract#5369) <#5369 (comment)> Brother, by your logic if it's for strict gmail. Then what about cases which uses their custom domain for example organization email or education email from Google workspace. What would happen to that? Will that work? … <#m_800436846468510565_> On Sat, 24 Jan, 2026, 5:05 pm Aira Jena, @.> wrote: @. commented on this pull request. ------------------------------ In fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/service/GmailBackedPlatformEmailService.java <#5369 (comment) <#5369 (comment)>>: > @@ -74,12 +74,16 @@ public void sendDefinedEmail(EmailDetail emailDetails) { props.put("mail.debug", "true"); // these are the added lines - props.put("mail.smtp.starttls.enable", "true"); - // props.put("mail.smtp.ssl.enable", "true"); - - props.put("mail.smtp.socketFactory.port", Integer.parseInt(smtpCredentialsData.getPort())); - props.put("mail.smtp.socketFactory.class", "javax.net.ssl.SSLSocketFactory");// NOSONAR - props.put("mail.smtp.socketFactory.fallback", "true"); + // Only apply strict Gmail settings if we are actually connecting to Gmail + if (smtpCredentialsData.getHost() != null && smtpCredentialsData.getHost().endsWith("gmail.com")) { The code already handles this - it only applies strict SSL/TLS settings when the SMTP host ends with gmail.com. For other providers (Yahoo, Outlook, ProtonMail, or even local testing with Mailhog), it uses relaxed settings without forcing the SSL SocketFactory. This way, the email service works with any SMTP provider configured in the external services settings. — Reply to this email directly, view it on GitHub <#5369 (comment) <#5369 (comment)>>, or unsubscribe https://github.com/notifications/unsubscribe-auth/AHV6TA63OV2P7O3AEJXFMS34INKIHAVCNFSM6AAAAACSUFDBTOVHI2DSMVQWIX3LMV43YUDVNRWFEZLROVSXG5CSMV3GSZLXHMZTOMBRGQ3TKMRRG4 . You are receiving this because you commented.Message ID: @.> The check is based on the SMTP host (smtpCredentialsData.getHost()), not the user's email domain. For Google Workspace with custom domains (e.g., @. or @.), the SMTP server is still smtp.gmail.com. So the condition getHost().endsWith("gmail.com") would correctly apply the Gmail-specific SSL settings. Examples: Gmail @.) → SMTP host: smtp.gmail.com → Gmail settings applied Google Workspace @.) → SMTP host: smtp.gmail.com → Gmail settings applied Outlook 365 @.) → SMTP host: smtp.office365.com → elaxed settings Yahoo → SMTP host: smtp.mail.yahoo.com → Relaxed settings The SMTP host is configured in Fineract's external services settings by the administrator, so it correctly identifies the mail provider regardless of the sender's email domain. — Reply to this email directly, view it on GitHub <#5369 (comment)>, or unsubscribe https://github.com/notifications/unsubscribe-auth/AHV6TA4DQKCTCOXHYJ4GUE34INXQXAVCNFSM6AAAAACSUFDBTOVHI2DSMVQWIX3LMV43OSLTON2WKQ3PNVWWK3TUHMZTOOJUGYZTEMJZGQ . You are receiving this because you commented.Message ID: @.***>

You make a very valid point. Ideally, these SSL/TLS settings (socketFactory, starttls, etc.) should be stored in the database configuration rather than inferred from the hostname. However, the current SMTPCredentialsData only supports basic fields (host, port, username, password). Making this fully configuration-driven would require schema changes, API updates, and UI changes to the External Services configuration, which is a significant refactor outside the scope of this "Forgot Password" feature. This logic was added as a pragmatic "bridge" to support standard Gmail configurations (which many users employ) while preventing breakage for non-standard providers (like Mailtrap/Mailhog/Outlook) that fail if SSLSocketFactory is forced. Would you be okay if we keep this safeguard for now to unblock this feature? I can create a follow-up issue/PR to refactor the Email Service to be fully generic and configuration-driven.

props.put("mail.smtp.socketFactory.class", "javax.net.ssl.SSLSocketFactory");// NOSONAR
props.put("mail.smtp.socketFactory.fallback", "true");
// Only apply strict Gmail settings if we are actually connecting to Gmail
if (smtpCredentialsData.getHost() != null && smtpCredentialsData.getHost().endsWith("gmail.com")) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this change in the source code is not part of the Jira ticket and must be removed. A new Jira ticket should be raised for improving or refactoring the connection for SMTP servers.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, the SMTP settings refactoring is outside the scope of this ticket. I have reverted those changes and updated the PR. I'll raise a separate ticket to handle the SMTP connection improvements.

@airajena airajena force-pushed the FINERACT-2006/forgot-password branch from abf2cff to b29e23b Compare January 25, 2026 07:47
Copy link
Contributor

@IOhacker IOhacker left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lgtm

Copy link
Contributor

@Aman-Mittal Aman-Mittal left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems ok now, but really curious what type of safeguards are implemented in this, Additionally we need to tests for the code coverage

@airajena
Copy link
Contributor Author

Seems ok now, but really curious what type of safeguards are implemented in this, Additionally we need to tests for the code coverage

Below things are on the safeguards side

  • The generated password is time-bound (24 hours). After expiry, it’s automatically invalid and cannot be used.
  • Once the user logs in using the temporary password, they are required to change it immediately, and the temporary credentials are cleared.
  • Any successful password update (either through this flow or via an admin action) invalidates the temporary password.
  • If email delivery fails, the transaction is rolled back so no orphaned temporary passwords are stored.
  • Password reset requests go through the service layer and are logged, so the activity is traceable

@Aman-Mittal
Copy link
Contributor

Aman-Mittal commented Jan 25, 2026 via email

@airajena
Copy link
Contributor Author

I think it would be a good practice if we can implement a limiter on this. Like you can use forgot password at certain times. Within a span of interval. Like in every 5-10 minutes at a time for single user?

On Sun, 25 Jan, 2026, 10:31 pm Aira Jena, @.> wrote: airajena left a comment (apache/fineract#5369) <#5369 (comment)> Seems ok now, but really curious what type of safeguards are implemented in this, Additionally we need to tests for the code coverage Below things are on the safeguards side - The generated password is time-bound (24 hours). After expiry, it’s automatically invalid and cannot be used. - Once the user logs in using the temporary password, they are required to change it immediately, and the temporary credentials are cleared. - Any successful password update (either through this flow or via an admin action) invalidates the temporary password. - If email delivery fails, the transaction is rolled back so no orphaned temporary passwords are stored. - Password reset requests go through the service layer and are logged, so the activity is traceable — Reply to this email directly, view it on GitHub <#5369 (comment)>, or unsubscribe https://github.com/notifications/unsubscribe-auth/AHV6TAZBOYWXIQOMB2PGVW34ITZHRAVCNFSM6AAAAACSUFDBTOVHI2DSMVQWIX3LMV43OSLTON2WKQ3PNVWWK3TUHMZTOOJWHE2TOMZRHA . You are receiving this because you commented.Message ID: @.>

I didn’t include it in this PR to keep the scope limited to the core “forgot password” functionality and avoid introducing additional persistence or infrastructure concerns. That said, I think this is definitely worth addressing. I’m happy to raise a follow-up JIRA ticket and work on the rate-limiting implementation separately so we can design it cleanly without blocking this feature. Let me know if that approach works for you. Also I wanted to ask one thing, is there any slack channel that I can get access for discussions?

@Aman-Mittal
Copy link
Contributor

Aman-Mittal commented Jan 25, 2026 via email

@Aman-Mittal
Copy link
Contributor

Aman-Mittal commented Jan 25, 2026 via email

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants