Merge feature/Backend-DSC-MFA into feature/merge_update_code
Brings TOTP / Google Authenticator MFA factor (device mapping at /v1/mfa/totp/setup + /verify, login via existing twofactor pipeline), DSC-2FA login UI fix, Individual-only filter on guardian/nominee/family link pickers, Title Case normalization on client names, and migration 0296 unique nominee per account type. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> # Conflicts: # .claude/settings.local.json # fineract-provider/logs/fineract.log
This commit is contained in:
@@ -48,9 +48,11 @@
|
||||
"PowerShell(Get-Process -Id 9340 -ErrorAction SilentlyContinue | Select-Object Id, ProcessName, StartTime, @{Name='MemMB';Expression={[math]::Round\\($_.WorkingSet64/1MB,0\\)}}, Path | Format-List)",
|
||||
"Bash(git -c core.editor=true rebase --continue)",
|
||||
"Bash(git rebase *)",
|
||||
"Bash(rm -rf .git/rebase-merge; git status -sb | head -3)"
|
||||
"Bash(curl -sk \"https://localhost:8443/fineract-provider/api/v1/clients?limit=5\" -H \"Fineract-Platform-TenantId: default\" -H \"Authorization: Basic bWlmb3M6cGFzc3dvcmQ=\")",
|
||||
"Bash(git add *)"
|
||||
"Bash(rm -rf .git/rebase-merge; git status -sb | head -3)",
|
||||
"Bash(git -c commit.gpgsign=false commit -m ' *)",
|
||||
"Bash(git pull *)",
|
||||
"Bash(./gradlew :fineract-security:compileJava :fineract-provider:compileJava -q)",
|
||||
"Bash(git ls-remote *)"
|
||||
],
|
||||
"additionalDirectories": [
|
||||
"c:\\Users\\adity\\Music\\CBS\\cfcfrms-bank-api",
|
||||
|
||||
@@ -95,6 +95,10 @@ dependencies {
|
||||
'com.squareup.okhttp3:okhttp',
|
||||
'com.squareup.okhttp3:okhttp-urlconnection',
|
||||
|
||||
// TOTP (Google Authenticator) — secret generation, QR code data URI, code verification.
|
||||
// Bundles ZXing transitively for QR PNG output (used by ZxingPngQrGenerator).
|
||||
'dev.samstevens.totp:totp:1.7.1',
|
||||
|
||||
'org.apache.commons:commons-lang3',
|
||||
'commons-io:commons-io',
|
||||
'org.apache.poi:poi',
|
||||
|
||||
+2671
-53599
File diff suppressed because it is too large
Load Diff
+5
@@ -379,6 +379,11 @@ public class SecurityConfig {
|
||||
.requestMatchers(API_MATCHER.matcher("/api/*/dsc/**")).fullyAuthenticated()
|
||||
.requestMatchers(API_MATCHER.matcher("/api/*/mfa/factors")).fullyAuthenticated()
|
||||
.requestMatchers(API_MATCHER.matcher("/api/*/mfa/factors/**")).fullyAuthenticated()
|
||||
// TOTP (Google Authenticator) device mapping. Same posture as the rest of the
|
||||
// self-service MFA endpoints — the user must be password-authenticated, but is
|
||||
// exempt from the TFA gate so they can complete (or repair) their enrolment
|
||||
// before they're able to satisfy 2FA on subsequent logins.
|
||||
.requestMatchers(API_MATCHER.matcher("/api/*/mfa/totp/**")).fullyAuthenticated()
|
||||
.requestMatchers(API_MATCHER.matcher("/api/**"))
|
||||
.access(allOf(authorizationManagers.toArray(new AuthorizationManager[0])));
|
||||
}).httpBasic(hb -> hb.authenticationEntryPoint(basicAuthenticationEntryPoint())).csrf(AbstractHttpConfigurer::disable)
|
||||
|
||||
+71
-12
@@ -39,6 +39,7 @@ import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.apache.fineract.useradministration.mfa.domain.MfaFactorType;
|
||||
import org.apache.fineract.useradministration.mfa.domain.UserMfaFactor;
|
||||
import org.apache.fineract.useradministration.mfa.domain.UserMfaFactorRepository;
|
||||
import org.apache.fineract.useradministration.mfa.totp.service.TotpService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -76,13 +77,14 @@ public class MfaFactorBackedTwoFactorServiceImpl implements TwoFactorService {
|
||||
private final TFAccessTokenRepository tfAccessTokenRepository;
|
||||
private final AccessTokenGenerationService accessTokenGenerationService;
|
||||
private final TwoFactorConfigurationService configurationService;
|
||||
private final TotpService totpService;
|
||||
|
||||
@Autowired
|
||||
public MfaFactorBackedTwoFactorServiceImpl(final UserMfaFactorRepository userMfaFactorRepository,
|
||||
final LoginOtpService smsLoginOtpService, final EmailLoginOtpService emailLoginOtpService,
|
||||
final OTPRequestRepository otpRequestRepository, final TFAccessTokenRepository tfAccessTokenRepository,
|
||||
final AccessTokenGenerationService accessTokenGenerationService,
|
||||
final TwoFactorConfigurationService configurationService) {
|
||||
final TwoFactorConfigurationService configurationService, final TotpService totpService) {
|
||||
this.userMfaFactorRepository = userMfaFactorRepository;
|
||||
this.smsLoginOtpService = smsLoginOtpService;
|
||||
this.emailLoginOtpService = emailLoginOtpService;
|
||||
@@ -90,6 +92,7 @@ public class MfaFactorBackedTwoFactorServiceImpl implements TwoFactorService {
|
||||
this.tfAccessTokenRepository = tfAccessTokenRepository;
|
||||
this.accessTokenGenerationService = accessTokenGenerationService;
|
||||
this.configurationService = configurationService;
|
||||
this.totpService = totpService;
|
||||
}
|
||||
|
||||
// ── Delivery methods ─────────────────────────────────────────────────────
|
||||
@@ -103,9 +106,15 @@ public class MfaFactorBackedTwoFactorServiceImpl implements TwoFactorService {
|
||||
}
|
||||
final String name = mapFactorTypeToDeliveryName(factor.getFactorType());
|
||||
if (name == null) {
|
||||
continue; // TOTP / TOKEN not supported by this OTP path
|
||||
continue; // TOKEN isn't routed through this OTP path
|
||||
}
|
||||
// TOTP has no SMS-style destination — fall back to the issuer label stored on the
|
||||
// factor row so the picker shows "Google Authenticator" / "CBSnexora" instead of an
|
||||
// empty cell. SMS/EMAIL still require a real destination to be shown.
|
||||
String target = factor.getDestination();
|
||||
if ((target == null || target.isBlank()) && factor.getFactorType() == MfaFactorType.TOTP) {
|
||||
target = "Google Authenticator";
|
||||
}
|
||||
final String target = factor.getDestination();
|
||||
if (target == null || target.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
@@ -123,18 +132,34 @@ public class MfaFactorBackedTwoFactorServiceImpl implements TwoFactorService {
|
||||
}
|
||||
final boolean isSms = TwoFactorConstants.SMS_DELIVERY_METHOD_NAME.equalsIgnoreCase(deliveryMethodName);
|
||||
final boolean isEmail = TwoFactorConstants.EMAIL_DELIVERY_METHOD_NAME.equalsIgnoreCase(deliveryMethodName);
|
||||
if (!isSms && !isEmail) {
|
||||
final boolean isTotp = TwoFactorConstants.TOTP_DELIVERY_METHOD_NAME.equalsIgnoreCase(deliveryMethodName);
|
||||
if (!isSms && !isEmail && !isTotp) {
|
||||
throw new OTPDeliveryMethodInvalidException();
|
||||
}
|
||||
|
||||
// The user must have a verified factor of the requested type.
|
||||
final MfaFactorType type = isSms ? MfaFactorType.SMS : MfaFactorType.EMAIL;
|
||||
final MfaFactorType type = isSms ? MfaFactorType.SMS : (isEmail ? MfaFactorType.EMAIL : MfaFactorType.TOTP);
|
||||
final UserMfaFactor factor = userMfaFactorRepository.findByAppUser_IdAndFactorType(user.getId(), type)
|
||||
.filter(UserMfaFactor::isVerified)
|
||||
.filter(f -> f.getDisabledOn() == null)
|
||||
.orElseThrow(OTPDeliveryMethodInvalidException::new);
|
||||
|
||||
// Send OTP via the appropriate channel (LincPay / SMTP) and persist the audit row.
|
||||
// TOTP is a "no delivery" factor — the code is generated locally by the user's authenticator
|
||||
// app. We still create an OTPRequest so the existing two-step /twofactor → /twofactor/validate
|
||||
// UI flow continues to work; the validate call routes back here and we verify against the
|
||||
// factor's stored secret instead of round-tripping to LincPay / SMTP.
|
||||
if (isTotp) {
|
||||
final OTPDeliveryMethod totpMethod = new OTPDeliveryMethod().setName(deliveryMethodName)
|
||||
.setTarget(factor.getDestination() != null ? factor.getDestination() : "Google Authenticator");
|
||||
final int liveTime = configurationService.getOTPTokenLiveTime();
|
||||
// Stash the factor id in the token field; createAccessTokenFromOTP reads it to find the
|
||||
// secret. Validity window is the same as OTP so the UI's countdown is consistent.
|
||||
final OTPRequest request = OTPRequest.create(String.valueOf(factor.getId()), liveTime, extendedAccessToken, totpMethod);
|
||||
otpRequestRepository.addOTPRequest(user, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
// SMS / EMAIL: send via the appropriate channel (LincPay / SMTP) and persist the audit row.
|
||||
final OtpGenerateResponse result = isSms
|
||||
? smsLoginOtpService.generate(user.getUsername(), null, null)
|
||||
: emailLoginOtpService.generate(user.getUsername(), null, null);
|
||||
@@ -165,6 +190,42 @@ public class MfaFactorBackedTwoFactorServiceImpl implements TwoFactorService {
|
||||
throw new OTPTokenInvalidException();
|
||||
}
|
||||
|
||||
final OTPMetadata metadata = otpRequest.getMetadata();
|
||||
final String channel = metadata != null && metadata.getDeliveryMethod() != null
|
||||
? metadata.getDeliveryMethod().getName()
|
||||
: null;
|
||||
|
||||
// TOTP path: no LincPay / SMTP round-trip. The token field carries the factor id, not an
|
||||
// otpLog id. Look up the secret on that factor and verify locally against the user's
|
||||
// 6-digit code.
|
||||
if (TwoFactorConstants.TOTP_DELIVERY_METHOD_NAME.equalsIgnoreCase(channel)) {
|
||||
final Long factorId;
|
||||
try {
|
||||
factorId = Long.parseLong(otpRequest.getToken());
|
||||
} catch (final NumberFormatException e) {
|
||||
throw new OTPTokenInvalidException();
|
||||
}
|
||||
final UserMfaFactor factor = userMfaFactorRepository.findById(factorId).orElseThrow(OTPTokenInvalidException::new);
|
||||
// Defence in depth: the factor must still belong to the user, still be verified, and not
|
||||
// disabled. The OTPRequest is per-user keyed already but an admin could have disabled
|
||||
// the factor between request and validate — refuse to mint a TFA token in that case.
|
||||
if (!factor.getAppUser().getId().equals(user.getId()) || !factor.isVerified() || factor.getDisabledOn() != null) {
|
||||
throw new OTPTokenInvalidException();
|
||||
}
|
||||
if (!totpService.verify(factor.getSecret(), otpToken)) {
|
||||
throw new OTPTokenInvalidException();
|
||||
}
|
||||
otpRequestRepository.deleteOTPRequestForUser(user);
|
||||
final int liveTime = metadata != null && metadata.isExtendedAccessToken()
|
||||
? configurationService.getAccessTokenExtendedLiveTime()
|
||||
: configurationService.getAccessTokenLiveTime();
|
||||
final String token = accessTokenGenerationService.generateRandomToken();
|
||||
final TFAccessToken accessToken = TFAccessToken.create(token, user, liveTime);
|
||||
tfAccessTokenRepository.save(accessToken);
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
// SMS / EMAIL path: token field is the m_otp_log id; route to the channel's verify service.
|
||||
final Long otpId;
|
||||
try {
|
||||
otpId = Long.parseLong(otpRequest.getToken());
|
||||
@@ -172,11 +233,6 @@ public class MfaFactorBackedTwoFactorServiceImpl implements TwoFactorService {
|
||||
throw new OTPTokenInvalidException();
|
||||
}
|
||||
|
||||
final OTPMetadata metadata = otpRequest.getMetadata();
|
||||
final String channel = metadata != null && metadata.getDeliveryMethod() != null
|
||||
? metadata.getDeliveryMethod().getName()
|
||||
: null;
|
||||
|
||||
final OtpVerifyResponse verifyResult;
|
||||
if (TwoFactorConstants.SMS_DELIVERY_METHOD_NAME.equalsIgnoreCase(channel)) {
|
||||
verifyResult = smsLoginOtpService.verify(otpId, otpToken);
|
||||
@@ -241,7 +297,10 @@ public class MfaFactorBackedTwoFactorServiceImpl implements TwoFactorService {
|
||||
return switch (type) {
|
||||
case SMS -> TwoFactorConstants.SMS_DELIVERY_METHOD_NAME;
|
||||
case EMAIL -> TwoFactorConstants.EMAIL_DELIVERY_METHOD_NAME;
|
||||
default -> null; // TOTP and TOKEN aren't routed through this OTP path
|
||||
// TOTP surfaces in the picker so users can choose Google Authenticator. The UI branches
|
||||
// on this name to skip the "send OTP" step (TOTP has no delivery) and prompt for a code.
|
||||
case TOTP -> TwoFactorConstants.TOTP_DELIVERY_METHOD_NAME;
|
||||
default -> null; // TOKEN / DSC handled separately
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* 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.useradministration.mfa.totp.api;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.useradministration.mfa.totp.data.TotpSetupResponse;
|
||||
import org.apache.fineract.useradministration.mfa.totp.service.TotpEnrollmentService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Self-service Google Authenticator (TOTP) device mapping.
|
||||
*
|
||||
* <ul>
|
||||
* <li>POST /v1/mfa/totp/setup — generate a secret + QR; UI shows the QR for the user to
|
||||
* scan with Google Authenticator / Authy / Microsoft Authenticator. Factor is stored
|
||||
* as <em>unverified</em>.</li>
|
||||
* <li>POST /v1/mfa/totp/verify — body {@code {"code":"123456"}}; on match the factor is
|
||||
* flipped to verified, and from then on counts toward the 2FA login gate.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Disabling the factor uses the existing DELETE /v1/mfa/factors/{id}.
|
||||
*/
|
||||
@Path("/v1/mfa/totp")
|
||||
@Component
|
||||
@Tag(name = "MFA TOTP", description = "Google Authenticator device mapping for the current user")
|
||||
@RequiredArgsConstructor
|
||||
public class TotpEnrollmentApiResource {
|
||||
|
||||
private final TotpEnrollmentService enrollmentService;
|
||||
|
||||
@POST
|
||||
@Path("setup")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Start TOTP device mapping. Generates a fresh secret and returns the QR "
|
||||
+ "data URI for the authenticator app to scan. Factor row is saved as unverified.")
|
||||
public TotpSetupResponse setup() {
|
||||
return enrollmentService.setupForCurrentUser();
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("verify")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Confirm device mapping by verifying the first 6-digit code from the "
|
||||
+ "authenticator app. On success the factor is marked verified and starts gating logins.")
|
||||
public Map<String, Object> verify(final String body) {
|
||||
final JsonObject payload = parse(body);
|
||||
final String code = stringOrNull(payload, "code");
|
||||
final Long factorId = enrollmentService.verifyForCurrentUser(code);
|
||||
return Map.of("verified", true, "factorId", factorId);
|
||||
}
|
||||
|
||||
private JsonObject parse(final String body) {
|
||||
if (body == null || body.isBlank()) {
|
||||
return new JsonObject();
|
||||
}
|
||||
try {
|
||||
return JsonParser.parseString(body).getAsJsonObject();
|
||||
} catch (final Exception e) {
|
||||
return new JsonObject();
|
||||
}
|
||||
}
|
||||
|
||||
private String stringOrNull(final JsonObject obj, final String key) {
|
||||
return obj.has(key) && obj.get(key).isJsonPrimitive() ? obj.get(key).getAsString() : null;
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* 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.useradministration.mfa.totp.data;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* Response payload for POST /v1/mfa/totp/setup. The UI renders {@link #qrDataUri} as an
|
||||
* <img> for scanning and shows {@link #secret} below for the "can't scan, enter manually"
|
||||
* fallback. {@link #otpAuthUrl} is the raw otpauth:// link — useful if you want a
|
||||
* "click to add" button on mobile that deep-links into the authenticator app.
|
||||
*
|
||||
* <p>The factor is persisted as <em>unverified</em>; the UI must then POST /v1/mfa/totp/verify
|
||||
* with a code from the user's app to flip it to verified.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class TotpSetupResponse {
|
||||
|
||||
/** The TOTP secret in base32. Show as the manual-entry fallback under the QR. */
|
||||
private String secret;
|
||||
|
||||
/** PNG data URI ("data:image/png;base64,...") that the UI can drop into an <img src>. */
|
||||
private String qrDataUri;
|
||||
|
||||
/** Raw otpauth:// URL the QR encodes. Useful for "click to add" deep links on mobile. */
|
||||
private String otpAuthUrl;
|
||||
|
||||
/** Issuer label shown inside the authenticator app (e.g. "CBSnexora"). */
|
||||
private String issuer;
|
||||
|
||||
/** The factor row id created on the server in PENDING (unverified) state. */
|
||||
private Long factorId;
|
||||
}
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* 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.useradministration.mfa.totp.service;
|
||||
|
||||
import dev.samstevens.totp.exceptions.QrGenerationException;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.apache.fineract.useradministration.mfa.domain.MfaFactorType;
|
||||
import org.apache.fineract.useradministration.mfa.domain.UserMfaFactor;
|
||||
import org.apache.fineract.useradministration.mfa.domain.UserMfaFactorRepository;
|
||||
import org.apache.fineract.useradministration.mfa.totp.data.TotpSetupResponse;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Two-step TOTP enrolment (device mapping):
|
||||
*
|
||||
* <ol>
|
||||
* <li>{@link #setupForCurrentUser()} — generate a fresh secret, upsert the user's TOTP factor
|
||||
* row with {@code verified=false}, return the secret + QR data URI for the UI to display.</li>
|
||||
* <li>{@link #verifyForCurrentUser(String)} — user types a 6-digit code from their authenticator
|
||||
* app; if it matches the stored secret, flip {@code verified=true}. From then on the factor
|
||||
* counts toward the login 2FA gate.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>Re-running setup before verifying is fine — it just overwrites the pending secret with a new
|
||||
* one (the old QR becomes useless). Re-running setup AFTER verification is rejected unless the
|
||||
* caller first disables the existing TOTP factor; we don't want a single bad click to silently
|
||||
* unbind a working authenticator app.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class TotpEnrollmentService {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(TotpEnrollmentService.class);
|
||||
|
||||
private final PlatformSecurityContext securityContext;
|
||||
private final UserMfaFactorRepository factorRepository;
|
||||
private final TotpService totpService;
|
||||
|
||||
@Value("${fineract.mfa.totp.issuer:CBSnexora}")
|
||||
private String issuer;
|
||||
|
||||
@Transactional
|
||||
public TotpSetupResponse setupForCurrentUser() {
|
||||
final AppUser user = securityContext.authenticatedUser();
|
||||
final UserMfaFactor existing = factorRepository.findByAppUser_IdAndFactorType(user.getId(), MfaFactorType.TOTP).orElse(null);
|
||||
|
||||
// Guardrail: refuse to overwrite a working TOTP secret. The user must explicitly disable
|
||||
// the current factor first (DELETE /v1/mfa/factors/{id}) — otherwise a single accidental
|
||||
// "Set up Google Authenticator" click would silently invalidate their device.
|
||||
if (existing != null && existing.isVerified() && existing.getDisabledOn() == null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.user.mfa.totp.already.enrolled",
|
||||
"Google Authenticator is already set up for this user. Disable the existing entry first to re-enrol.");
|
||||
}
|
||||
|
||||
final String secret = totpService.generateSecret();
|
||||
final String username = user.getUsername();
|
||||
|
||||
final UserMfaFactor factor = existing != null ? existing : new UserMfaFactor();
|
||||
factor.setAppUser(user);
|
||||
factor.setFactorType(MfaFactorType.TOTP);
|
||||
factor.setSecret(secret);
|
||||
// TOTP has no SMS-like destination — label the factor with the issuer so the picker shows
|
||||
// something readable ("Google Authenticator" / "CBSnexora") instead of an empty cell.
|
||||
factor.setDestination(issuer);
|
||||
factor.setVerified(false);
|
||||
factor.setVerifiedOn(null);
|
||||
factor.setDisabledOn(null);
|
||||
factor.setDisabledBy(null);
|
||||
final UserMfaFactor saved = factorRepository.saveAndFlush(factor);
|
||||
|
||||
final String otpAuthUrl = totpService.buildOtpAuthUrl(username, secret);
|
||||
final String qrDataUri;
|
||||
try {
|
||||
qrDataUri = totpService.generateQrDataUri(username, secret);
|
||||
} catch (final QrGenerationException e) {
|
||||
LOG.error("TOTP QR generation failed for user {}: {}", username, e.getMessage());
|
||||
throw new PlatformDataIntegrityException("error.msg.user.mfa.totp.qr.failed",
|
||||
"Could not render the Google Authenticator QR code. Try again or use the manual-entry secret.");
|
||||
}
|
||||
|
||||
LOG.info("TOTP enrolment started — user={}, factorId={}", username, saved.getId());
|
||||
|
||||
return TotpSetupResponse.builder()
|
||||
.secret(secret)
|
||||
.qrDataUri(qrDataUri)
|
||||
.otpAuthUrl(otpAuthUrl)
|
||||
.issuer(issuer)
|
||||
.factorId(saved.getId())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies the user-supplied 6-digit code against the pending secret and, on match, promotes
|
||||
* the factor to verified. Returns the factor id so the UI can show the user the row id in any
|
||||
* follow-up "manage MFA" screen.
|
||||
*/
|
||||
@Transactional
|
||||
public Long verifyForCurrentUser(final String code) {
|
||||
if (code == null || code.isBlank()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.user.mfa.totp.code.required", "code is required");
|
||||
}
|
||||
final AppUser user = securityContext.authenticatedUser();
|
||||
final UserMfaFactor factor = factorRepository.findByAppUser_IdAndFactorType(user.getId(), MfaFactorType.TOTP)
|
||||
.orElseThrow(() -> new PlatformDataIntegrityException("error.msg.user.mfa.totp.not.set.up",
|
||||
"Google Authenticator has not been set up — call POST /v1/mfa/totp/setup first."));
|
||||
|
||||
if (factor.getSecret() == null || factor.getSecret().isBlank()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.user.mfa.totp.secret.missing",
|
||||
"Stored TOTP secret is empty — re-run setup.");
|
||||
}
|
||||
|
||||
if (!totpService.verify(factor.getSecret(), code)) {
|
||||
throw new PlatformDataIntegrityException("error.msg.user.mfa.totp.code.invalid",
|
||||
"Code did not match. Make sure your device clock is correct and try the next one.");
|
||||
}
|
||||
|
||||
factor.setVerified(true);
|
||||
factor.setVerifiedOn(OffsetDateTime.now(ZoneOffset.UTC));
|
||||
factor.setDisabledOn(null);
|
||||
factor.setDisabledBy(null);
|
||||
final UserMfaFactor saved = factorRepository.saveAndFlush(factor);
|
||||
|
||||
LOG.info("TOTP verified — user={}, factorId={}", user.getUsername(), saved.getId());
|
||||
return saved.getId();
|
||||
}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* 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.useradministration.mfa.totp.service;
|
||||
|
||||
import dev.samstevens.totp.code.CodeGenerator;
|
||||
import dev.samstevens.totp.code.CodeVerifier;
|
||||
import dev.samstevens.totp.code.DefaultCodeGenerator;
|
||||
import dev.samstevens.totp.code.DefaultCodeVerifier;
|
||||
import dev.samstevens.totp.code.HashingAlgorithm;
|
||||
import dev.samstevens.totp.exceptions.QrGenerationException;
|
||||
import dev.samstevens.totp.qr.QrData;
|
||||
import dev.samstevens.totp.qr.QrGenerator;
|
||||
import dev.samstevens.totp.qr.ZxingPngQrGenerator;
|
||||
import dev.samstevens.totp.secret.DefaultSecretGenerator;
|
||||
import dev.samstevens.totp.secret.SecretGenerator;
|
||||
import dev.samstevens.totp.time.SystemTimeProvider;
|
||||
import dev.samstevens.totp.time.TimeProvider;
|
||||
import dev.samstevens.totp.util.Utils;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* TOTP (Google Authenticator) primitives: secret generation, otpauth URL, QR-image data URI,
|
||||
* and code verification. SHA1 / 6 digits / 30 second period — the standard combo every consumer
|
||||
* authenticator app (Google Authenticator, Authy, Microsoft Authenticator, 1Password) accepts
|
||||
* without configuration.
|
||||
*/
|
||||
@Service
|
||||
public class TotpService {
|
||||
|
||||
private final SecretGenerator secretGenerator = new DefaultSecretGenerator();
|
||||
private final QrGenerator qrGenerator = new ZxingPngQrGenerator();
|
||||
private final CodeGenerator codeGenerator = new DefaultCodeGenerator(HashingAlgorithm.SHA1, 6);
|
||||
private final TimeProvider timeProvider = new SystemTimeProvider();
|
||||
private final CodeVerifier codeVerifier = new DefaultCodeVerifier(codeGenerator, timeProvider);
|
||||
|
||||
/**
|
||||
* Issuer label shown inside the authenticator app (right above the 6-digit code). Override per
|
||||
* deployment with FINERACT_MFA_TOTP_ISSUER — the label is purely cosmetic but appears in the
|
||||
* scanned QR, so it should match the bank's brand to avoid confusing users.
|
||||
*/
|
||||
@Value("${fineract.mfa.totp.issuer:CBSnexora}")
|
||||
private String issuer;
|
||||
|
||||
public String generateSecret() {
|
||||
return secretGenerator.generate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the standard otpauth:// URL that the QR encodes. Returned alongside the QR so the UI
|
||||
* can offer a "can't scan? enter manually" fallback that the authenticator app can deep-link.
|
||||
*/
|
||||
public String buildOtpAuthUrl(final String username, final String secret) {
|
||||
final String label = issuer + ":" + username;
|
||||
return String.format("otpauth://totp/%s?secret=%s&issuer=%s&algorithm=SHA1&digits=6&period=30",
|
||||
urlEncode(label), secret, urlEncode(issuer));
|
||||
}
|
||||
|
||||
/** PNG data-URI (base64) suitable for putting straight into an <img src> attribute. */
|
||||
public String generateQrDataUri(final String username, final String secret) throws QrGenerationException {
|
||||
final QrData data = new QrData.Builder()
|
||||
.label(username)
|
||||
.secret(secret)
|
||||
.issuer(issuer)
|
||||
.algorithm(HashingAlgorithm.SHA1)
|
||||
.digits(6)
|
||||
.period(30)
|
||||
.build();
|
||||
final byte[] imageData = qrGenerator.generate(data);
|
||||
return Utils.getDataUriForImage(imageData, qrGenerator.getImageMimeType());
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies a user-submitted 6-digit code against the stored secret. DefaultCodeVerifier
|
||||
* accepts a 1-step window on either side of the current 30 s slot, so a code typed in the
|
||||
* last second of one window is still accepted as the user hits enter.
|
||||
*/
|
||||
public boolean verify(final String secret, final String code) {
|
||||
if (secret == null || code == null) {
|
||||
return false;
|
||||
}
|
||||
return codeVerifier.isValidCode(secret, code.trim());
|
||||
}
|
||||
|
||||
private static String urlEncode(final String s) {
|
||||
return URLEncoder.encode(s, StandardCharsets.UTF_8).replace("+", "%20");
|
||||
}
|
||||
}
|
||||
+5
@@ -28,6 +28,11 @@ public final class TwoFactorConstants {
|
||||
|
||||
public static final String SMS_DELIVERY_METHOD_NAME = "sms";
|
||||
public static final String EMAIL_DELIVERY_METHOD_NAME = "email";
|
||||
// TOTP / Google Authenticator. Surfaced in the same /v1/twofactor delivery-method list as
|
||||
// sms/email so the UI offers it as a sibling option; verification is then code-vs-secret
|
||||
// (no LincPay round-trip, no SMTP send). The factor row in m_user_mfa_factor.secret holds
|
||||
// the base32 secret the user paired with their app via /v1/mfa/totp/setup + /verify.
|
||||
public static final String TOTP_DELIVERY_METHOD_NAME = "totp";
|
||||
|
||||
public static final String BYPASS_TWO_FACTOR_PERMISSION = "BYPASS_TWOFACTOR";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user