Add Google Authenticator (TOTP) as MFA factor: device mapping + login

Backend:
- New dependency dev.samstevens.totp:totp:1.7.1 (bundles ZXing for QR PNG).
- TotpService: secret/QR/otpauth-URL/verify primitives. SHA1, 6 digits, 30s —
  the standard combo every consumer authenticator app accepts unconfigured.
  Issuer label is configurable via FINERACT_MFA_TOTP_ISSUER, defaults to
  "CBSnexora".
- Two-step device mapping driven by TotpEnrollmentService:
  * POST /v1/mfa/totp/setup  — generate secret, upsert m_user_mfa_factor row
    as factor_type=TOTP/verified=false, return {secret, qrDataUri, otpAuthUrl,
    issuer, factorId}. Refuses to overwrite an already-verified-and-active
    factor so an accidental click can't unbind the user's working device.
  * POST /v1/mfa/totp/verify — body {code}; verify against the stored secret
    and flip verified=true on success.
- Login wiring in MfaFactorBackedTwoFactorServiceImpl:
  * getDeliveryMethodsForUser surfaces verified TOTP factors as
    {name:"totp", target:"Google Authenticator"} so the picker offers it
    alongside SMS/EMAIL.
  * createNewOTPToken("totp") creates a no-op OTPRequest (no LincPay/SMTP
    round-trip) carrying the factor id in the token field so validate can
    look up the secret.
  * createAccessTokenFromOTP branches on channel — TOTP path verifies code
    vs factor.secret with totp library (sliding 1-step window) and mints
    a TFAccessToken with the same lifetime + extended-token logic as OTP.
  * Defence-in-depth: re-checks at validate time that the factor still
    belongs to the user, is verified, and not disabled.
- TwoFactorConstants.TOTP_DELIVERY_METHOD_NAME = "totp".
- SecurityConfig: /api/*/mfa/totp/** is fullyAuthenticated() (exempt from
  the TFA gate so users can enrol before they can satisfy 2FA).

UI changes pending (see chat): add a "Google Authenticator" tile on the
MFA Factors settings page that calls /setup → shows QR + secret → calls
/verify; extend two-factor-authentication.component.ts iconFor() and copy
for the new "totp" delivery method. Existing requestOTP / validateOTP
pipeline handles the login flow transparently.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aditya
2026-05-15 17:41:55 +05:30
co-authored by Claude Opus 4.7
parent 6558ea6998
commit 45a041b023
10 changed files with 3150 additions and 13 deletions
+5 -1
View File
@@ -48,7 +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(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",
+4
View File
@@ -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',
File diff suppressed because it is too large Load Diff
@@ -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)
@@ -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
};
}
}
@@ -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;
}
}
@@ -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
* &lt;img&gt; 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 &lt;img src&gt;. */
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;
}
@@ -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();
}
}
@@ -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 &lt;img src&gt; 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");
}
}
@@ -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";