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)", "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 -c core.editor=true rebase --continue)",
"Bash(git rebase *)", "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": [ "additionalDirectories": [
"c:\\Users\\adity\\Music\\CBS\\cfcfrms-bank-api", "c:\\Users\\adity\\Music\\CBS\\cfcfrms-bank-api",
+4
View File
@@ -95,6 +95,10 @@ dependencies {
'com.squareup.okhttp3:okhttp', 'com.squareup.okhttp3:okhttp',
'com.squareup.okhttp3:okhttp-urlconnection', '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', 'org.apache.commons:commons-lang3',
'commons-io:commons-io', 'commons-io:commons-io',
'org.apache.poi:poi', '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/*/dsc/**")).fullyAuthenticated()
.requestMatchers(API_MATCHER.matcher("/api/*/mfa/factors")).fullyAuthenticated() .requestMatchers(API_MATCHER.matcher("/api/*/mfa/factors")).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/**")) .requestMatchers(API_MATCHER.matcher("/api/**"))
.access(allOf(authorizationManagers.toArray(new AuthorizationManager[0]))); .access(allOf(authorizationManagers.toArray(new AuthorizationManager[0])));
}).httpBasic(hb -> hb.authenticationEntryPoint(basicAuthenticationEntryPoint())).csrf(AbstractHttpConfigurer::disable) }).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.MfaFactorType;
import org.apache.fineract.useradministration.mfa.domain.UserMfaFactor; import org.apache.fineract.useradministration.mfa.domain.UserMfaFactor;
import org.apache.fineract.useradministration.mfa.domain.UserMfaFactorRepository; import org.apache.fineract.useradministration.mfa.domain.UserMfaFactorRepository;
import org.apache.fineract.useradministration.mfa.totp.service.TotpService;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@@ -76,13 +77,14 @@ public class MfaFactorBackedTwoFactorServiceImpl implements TwoFactorService {
private final TFAccessTokenRepository tfAccessTokenRepository; private final TFAccessTokenRepository tfAccessTokenRepository;
private final AccessTokenGenerationService accessTokenGenerationService; private final AccessTokenGenerationService accessTokenGenerationService;
private final TwoFactorConfigurationService configurationService; private final TwoFactorConfigurationService configurationService;
private final TotpService totpService;
@Autowired @Autowired
public MfaFactorBackedTwoFactorServiceImpl(final UserMfaFactorRepository userMfaFactorRepository, public MfaFactorBackedTwoFactorServiceImpl(final UserMfaFactorRepository userMfaFactorRepository,
final LoginOtpService smsLoginOtpService, final EmailLoginOtpService emailLoginOtpService, final LoginOtpService smsLoginOtpService, final EmailLoginOtpService emailLoginOtpService,
final OTPRequestRepository otpRequestRepository, final TFAccessTokenRepository tfAccessTokenRepository, final OTPRequestRepository otpRequestRepository, final TFAccessTokenRepository tfAccessTokenRepository,
final AccessTokenGenerationService accessTokenGenerationService, final AccessTokenGenerationService accessTokenGenerationService,
final TwoFactorConfigurationService configurationService) { final TwoFactorConfigurationService configurationService, final TotpService totpService) {
this.userMfaFactorRepository = userMfaFactorRepository; this.userMfaFactorRepository = userMfaFactorRepository;
this.smsLoginOtpService = smsLoginOtpService; this.smsLoginOtpService = smsLoginOtpService;
this.emailLoginOtpService = emailLoginOtpService; this.emailLoginOtpService = emailLoginOtpService;
@@ -90,6 +92,7 @@ public class MfaFactorBackedTwoFactorServiceImpl implements TwoFactorService {
this.tfAccessTokenRepository = tfAccessTokenRepository; this.tfAccessTokenRepository = tfAccessTokenRepository;
this.accessTokenGenerationService = accessTokenGenerationService; this.accessTokenGenerationService = accessTokenGenerationService;
this.configurationService = configurationService; this.configurationService = configurationService;
this.totpService = totpService;
} }
// ── Delivery methods ───────────────────────────────────────────────────── // ── Delivery methods ─────────────────────────────────────────────────────
@@ -103,9 +106,15 @@ public class MfaFactorBackedTwoFactorServiceImpl implements TwoFactorService {
} }
final String name = mapFactorTypeToDeliveryName(factor.getFactorType()); final String name = mapFactorTypeToDeliveryName(factor.getFactorType());
if (name == null) { 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()) { if (target == null || target.isBlank()) {
continue; continue;
} }
@@ -123,18 +132,34 @@ public class MfaFactorBackedTwoFactorServiceImpl implements TwoFactorService {
} }
final boolean isSms = TwoFactorConstants.SMS_DELIVERY_METHOD_NAME.equalsIgnoreCase(deliveryMethodName); final boolean isSms = TwoFactorConstants.SMS_DELIVERY_METHOD_NAME.equalsIgnoreCase(deliveryMethodName);
final boolean isEmail = TwoFactorConstants.EMAIL_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(); throw new OTPDeliveryMethodInvalidException();
} }
// The user must have a verified factor of the requested type. // 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) final UserMfaFactor factor = userMfaFactorRepository.findByAppUser_IdAndFactorType(user.getId(), type)
.filter(UserMfaFactor::isVerified) .filter(UserMfaFactor::isVerified)
.filter(f -> f.getDisabledOn() == null) .filter(f -> f.getDisabledOn() == null)
.orElseThrow(OTPDeliveryMethodInvalidException::new); .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 final OtpGenerateResponse result = isSms
? smsLoginOtpService.generate(user.getUsername(), null, null) ? smsLoginOtpService.generate(user.getUsername(), null, null)
: emailLoginOtpService.generate(user.getUsername(), null, null); : emailLoginOtpService.generate(user.getUsername(), null, null);
@@ -165,6 +190,42 @@ public class MfaFactorBackedTwoFactorServiceImpl implements TwoFactorService {
throw new OTPTokenInvalidException(); 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; final Long otpId;
try { try {
otpId = Long.parseLong(otpRequest.getToken()); otpId = Long.parseLong(otpRequest.getToken());
@@ -172,11 +233,6 @@ public class MfaFactorBackedTwoFactorServiceImpl implements TwoFactorService {
throw new OTPTokenInvalidException(); throw new OTPTokenInvalidException();
} }
final OTPMetadata metadata = otpRequest.getMetadata();
final String channel = metadata != null && metadata.getDeliveryMethod() != null
? metadata.getDeliveryMethod().getName()
: null;
final OtpVerifyResponse verifyResult; final OtpVerifyResponse verifyResult;
if (TwoFactorConstants.SMS_DELIVERY_METHOD_NAME.equalsIgnoreCase(channel)) { if (TwoFactorConstants.SMS_DELIVERY_METHOD_NAME.equalsIgnoreCase(channel)) {
verifyResult = smsLoginOtpService.verify(otpId, otpToken); verifyResult = smsLoginOtpService.verify(otpId, otpToken);
@@ -241,7 +297,10 @@ public class MfaFactorBackedTwoFactorServiceImpl implements TwoFactorService {
return switch (type) { return switch (type) {
case SMS -> TwoFactorConstants.SMS_DELIVERY_METHOD_NAME; case SMS -> TwoFactorConstants.SMS_DELIVERY_METHOD_NAME;
case EMAIL -> TwoFactorConstants.EMAIL_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 SMS_DELIVERY_METHOD_NAME = "sms";
public static final String EMAIL_DELIVERY_METHOD_NAME = "email"; 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"; public static final String BYPASS_TWO_FACTOR_PERMISSION = "BYPASS_TWOFACTOR";