Add MFA backend: SMS/email OTP, factor management, OTP audit log

- Login 2FA via @Primary MfaFactorBackedTwoFactorServiceImpl (replaces legacy)
- LincPay SMS OTP and SMTP email OTP with auto-promote to m_user_mfa_factor
- Reusable EmailSenderService + EmailTemplateService (CBSnexora branding)
- Admin OTP audit viewer (GET /v1/otp-logs) with paging, search, masking
- Self-service MFA factor API at /v1/mfa/factors
- READ_OTP_LOG permission + OTP Log Auditor role + bankadmin direct grant
- JsonAuthenticationEntryPoint suppresses Basic-auth popup for SPA paths
- TwoFactorAuthenticationFilter grants TWOFACTOR_AUTHENTICATED when no challenge
- Soft-delete + audit columns + deletion audit + version on client tables
- CIF number generator (TTYYYYNNNNNN format)
- DocumentNotFoundExceptionMapper to silence noisy WARN logs on missing images
- Liquibase migrations 0278-0291 (MFA, OTP log, audit/soft-delete, role wiring)

Note: application.properties intentionally not committed — contains local DB
and Gmail App Password fallbacks. Set SPRING_MAIL_PASSWORD,
FINERACT_DEFAULT_TENANTDB_PWD, FINERACT_HIKARI_PASSWORD as env vars in prod.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aditya
2026-05-10 13:19:10 +05:30
co-authored by Claude Opus 4.7
parent c51a36b4e9
commit 5e3212dc54
87 changed files with 5262 additions and 65 deletions
@@ -39,6 +39,7 @@ import java.util.Base64;
import java.util.Collection;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
import org.apache.fineract.infrastructure.core.data.EnumOptionData;
import org.apache.fineract.infrastructure.core.serialization.ToApiJsonSerializer;
import org.apache.fineract.infrastructure.security.constants.TwoFactorConstants;
@@ -49,6 +50,7 @@ import org.apache.fineract.portfolio.client.service.ClientReadPlatformService;
import org.apache.fineract.useradministration.data.RoleData;
import org.apache.fineract.useradministration.domain.AppUser;
import org.apache.fineract.useradministration.domain.Role;
import org.apache.fineract.useradministration.mfa.domain.UserMfaFactorRepository;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
@@ -79,6 +81,8 @@ public class AuthenticationApiResource {
private final ToApiJsonSerializer<AuthenticatedUserData> apiJsonSerializerService;
private final SpringSecurityPlatformSecurityContext springSecurityPlatformSecurityContext;
private final ClientReadPlatformService clientReadPlatformService;
private final ConfigurationDomainService configurationDomainService;
private final UserMfaFactorRepository userMfaFactorRepository;
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@@ -132,7 +136,18 @@ public class AuthenticationApiResource {
final EnumOptionData organisationalRole = principal.organisationalRoleData();
boolean isTwoFactorRequired = this.twoFactorEnabled
// 2FA is required when ALL of the following hold:
// 1) the framework is enabled at all (env: fineract.security.2fa.enabled=true)
// 2) the global kill-switch is on (c_configuration: mfa-factor-enforcement=true)
// 3) this specific user has at least one verified, non-disabled factor enrolled
// 4) the user does NOT have the BYPASS_TWO_FACTOR permission (break-glass for admins)
// If any of these is false, the user is allowed through without a challenge — preserves
// safe rollout (the per-user gate naturally lets unenrolled users in) and instant
// rollback (flip the c_configuration row off and everyone is unchallenged).
final boolean enforcementEnabled = configurationDomainService.isMfaFactorEnforcementEnabled();
final boolean userHasFactors = enforcementEnabled
&& userMfaFactorRepository.existsByAppUser_IdAndVerifiedTrueAndDisabledOnIsNull(principal.getId());
final boolean isTwoFactorRequired = this.twoFactorEnabled && enforcementEnabled && userHasFactors
&& !principal.hasSpecificPermissionTo(TwoFactorConstants.BYPASS_TWO_FACTOR_PERMISSION);
Long userId = principal.getId();
if (this.springSecurityPlatformSecurityContext.doesPasswordHasToBeRenewed(principal)) {
@@ -28,11 +28,13 @@ import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
import org.apache.fineract.infrastructure.security.constants.TwoFactorConstants;
import org.apache.fineract.infrastructure.security.data.FineractJwtAuthenticationToken;
import org.apache.fineract.infrastructure.security.domain.TFAccessToken;
import org.apache.fineract.infrastructure.security.service.TwoFactorService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.apache.fineract.useradministration.mfa.domain.UserMfaFactorRepository;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
@@ -57,6 +59,8 @@ import org.springframework.web.filter.GenericFilterBean;
public class TwoFactorAuthenticationFilter extends GenericFilterBean {
private final TwoFactorService twoFactorService;
private final ConfigurationDomainService configurationDomainService;
private final UserMfaFactorRepository userMfaFactorRepository;
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
@@ -78,24 +82,37 @@ public class TwoFactorAuthenticationFilter extends GenericFilterBean {
return;
}
if (!user.hasSpecificPermissionTo(TwoFactorConstants.BYPASS_TWO_FACTOR_PERMISSION)) {
// User can't bypass two-factor auth, check two-factor access
// token
// Mirror the same gating that AuthenticationApiResource uses to decide
// isTwoFactorAuthenticationRequired. If this user shouldn't be challenged, grant the
// TWOFACTOR_AUTHENTICATED authority silently so the SecurityConfig's downstream
// hasAuthority("TWOFACTOR_AUTHENTICATED") check passes. Without this the filter chain
// returns 403 on every protected endpoint when 2fa.enabled=true is on but the user
// has no enrolled factors (or the global kill-switch is off).
final boolean hasBypass = user.hasSpecificPermissionTo(TwoFactorConstants.BYPASS_TWO_FACTOR_PERMISSION);
final boolean enforcementOn = configurationDomainService.isMfaFactorEnforcementEnabled();
final boolean userHasFactors = enforcementOn
&& userMfaFactorRepository.existsByAppUser_IdAndVerifiedTrueAndDisabledOnIsNull(user.getId());
final boolean shouldChallenge = !hasBypass && enforcementOn && userHasFactors;
if (shouldChallenge) {
// User must present a valid TFA token
String token = request.getHeader("Fineract-Platform-TFA-Token");
if (token != null) {
TFAccessToken accessToken = twoFactorService.fetchAccessTokenForUser(user, token);
// Token is non-existent or invalid
if (accessToken == null || !accessToken.isValid()) {
response.addHeader("WWW-Authenticate", "Basic realm=\"Fineract Platform API Two Factor\"");
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid two-factor access token provided");
return;
}
} else {
// No token provided
if (token == null) {
// No token — let the request continue without TWOFACTOR_AUTHENTICATED.
// Downstream SecurityConfig will reject with 403, which signals the UI to
// run the 2FA flow.
chain.doFilter(req, res);
return;
}
TFAccessToken accessToken = twoFactorService.fetchAccessTokenForUser(user, token);
if (accessToken == null || !accessToken.isValid()) {
response.addHeader("WWW-Authenticate", "Basic realm=\"Fineract Platform API Two Factor\"");
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid two-factor access token provided");
return;
}
// Valid token → fall through and grant the authority
}
// If !shouldChallenge OR a valid token was supplied: grant TWOFACTOR_AUTHENTICATED.
List<GrantedAuthority> updatedAuthorities = new ArrayList<>(authentication.getAuthorities());
updatedAuthorities.add(new SimpleGrantedAuthority("TWOFACTOR_AUTHENTICATED"));