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:
co-authored by
Claude Opus 4.7
parent
c51a36b4e9
commit
5e3212dc54
+16
-1
@@ -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)) {
|
||||
|
||||
+30
-13
@@ -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"));
|
||||
|
||||
Reference in New Issue
Block a user