merege code feature/Backend-DSC-MFA branch

This commit is contained in:
Nitesh Yadav
2026-05-12 12:48:43 +05:30
54 changed files with 5428 additions and 9 deletions
@@ -152,6 +152,21 @@ public class SecurityConfig {
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/login/otp/verify")).permitAll()
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/login/otp/email/generate")).permitAll()
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/login/otp/email/verify")).permitAll()
// DSC enrollment — nonce-gated endpoints the JWS helper and the browser hit
// without a Fineract auth header. The randomNo is the capability token.
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/dsc/enroll/*.jnlp")).permitAll()
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/dsc/enroll/*/callback")).permitAll()
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/dsc/enroll/*/status")).permitAll()
// Helper JAR — Java Web Start fetches this without an auth header. The JAR is
// (self-)signed; that signature is what the JVM enforces, not Fineract auth.
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/dsc/helper.jar")).permitAll()
// DSC login factor — JNLP / helper-verify / status are nonce-gated. The
// /challenge create endpoint is NOT permitAll'd here because it must be reached
// by a basic-authed (but not-yet-2FA'd) user — handled by the twofactor matcher
// block below for /api/*/login/dsc which only requires fullyAuthenticated.
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/login/dsc/*.jnlp")).permitAll()
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/login/dsc/*/verify")).permitAll()
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/login/dsc/*/status")).permitAll()
.requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/instance-mode")).permitAll()
// businessdate
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/businessdate/*"))
@@ -348,6 +363,13 @@ public class SecurityConfig {
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/twofactor/validate")).fullyAuthenticated()
.requestMatchers(API_MATCHER.matcher("/api/*/twofactor")).fullyAuthenticated()
// POST /login/dsc/challenge requires a basic-authed user (no TFA yet) — same
// posture as /twofactor/validate. The other /login/dsc/** paths are nonce-gated
// permitAll above.
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/login/dsc/challenge")).fullyAuthenticated()
// /claim hands the minted TFA token back to the browser — must be basic-authed
// so we can prove the caller owns the challenge.
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/login/dsc/*/claim")).fullyAuthenticated()
.requestMatchers(API_MATCHER.matcher("/api/**"))
.access(allOf(authorizationManagers.toArray(new AuthorizationManager[0])));
}).httpBasic(hb -> hb.authenticationEntryPoint(basicAuthenticationEntryPoint())).csrf(AbstractHttpConfigurer::disable)
@@ -402,7 +424,12 @@ public class SecurityConfig {
.getBean(org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService.class);
org.apache.fineract.useradministration.mfa.domain.UserMfaFactorRepository userMfaFactorRepository = applicationContext
.getBean(org.apache.fineract.useradministration.mfa.domain.UserMfaFactorRepository.class);
return new TwoFactorAuthenticationFilter(twoFactorService, configurationDomainService, userMfaFactorRepository);
// DSC factor: same dependency pickup as AuthorizationServerConfig — the filter's
// "user has any factor" check now also accepts an active DSC mapping.
org.apache.fineract.useradministration.dsc.domain.BankUserDscRepository bankUserDscRepository = applicationContext
.getBean(org.apache.fineract.useradministration.dsc.domain.BankUserDscRepository.class);
return new TwoFactorAuthenticationFilter(twoFactorService, configurationDomainService, userMfaFactorRepository,
bankUserDscRepository);
}
public FineractInstanceModeApiFilter fineractInstanceModeApiFilter() {
@@ -291,7 +291,12 @@ public class AuthorizationServerConfig {
.getBean(org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService.class);
org.apache.fineract.useradministration.mfa.domain.UserMfaFactorRepository userMfaFactorRepository = applicationContext
.getBean(org.apache.fineract.useradministration.mfa.domain.UserMfaFactorRepository.class);
return new TwoFactorAuthenticationFilter(twoFactorService, configurationDomainService, userMfaFactorRepository);
// DSC factor: the filter's 2FA gate now also accepts an active DSC mapping as a "user has
// a factor" signal. Same Spring context lookup pattern as the other dependencies above.
org.apache.fineract.useradministration.dsc.domain.BankUserDscRepository bankUserDscRepository = applicationContext
.getBean(org.apache.fineract.useradministration.dsc.domain.BankUserDscRepository.class);
return new TwoFactorAuthenticationFilter(twoFactorService, configurationDomainService, userMfaFactorRepository,
bankUserDscRepository);
}
public FineractInstanceModeApiFilter fineractInstanceModeApiFilter() {
@@ -22,8 +22,10 @@ import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.HexFormat;
import org.apache.fineract.infrastructure.email.service.EmailSenderService;
import org.apache.fineract.infrastructure.email.service.EmailTemplateService;
@@ -57,12 +59,17 @@ public class EmailLoginOtpServiceImpl implements EmailLoginOtpService {
private static final String STATUS_VERIFIED = "VERIFIED";
private static final String STATUS_EXPIRED = "EXPIRED";
private static final DateTimeFormatter SUBJECT_TIMESTAMP = DateTimeFormatter.ofPattern("dd-MM-yy-HH:mm");
@Value("${fineract.security.otp.ttl-seconds:300}")
private int ttlSeconds;
@Value("${fineract.security.otp.max-attempts:5}")
private int maxAttempts;
@Value("${fineract.email.from-name:CBSnexora}")
private String appName;
private final OtpLogRepository otpLogRepository;
private final AppUserRepository appUserRepository;
private final EmailSenderService emailSenderService;
@@ -120,7 +127,7 @@ public class EmailLoginOtpServiceImpl implements EmailLoginOtpService {
// Render the branded template — same look and feel for OTP, welcome, password-reset, alerts.
final String html = emailTemplateService.renderOtp(recipientName, otp, validityMinutes);
final String subject = "Your CBSnexora Sign-in OTP";
final String subject = "Your OTP for Login - " + appName + " (" + LocalDateTime.now().format(SUBJECT_TIMESTAMP) + ")";
final boolean sent = emailSenderService.sendHtml(email, subject, html);
if (sent) {
@@ -34,6 +34,18 @@ public interface ClientKycWritePlatformService {
*/
void initiateKycInline(Client client, String kycJson);
/**
* Create-or-update inline counterpart to {@link #initiateKycInline} — used by the edit-client
* flow so the {@code kyc} sub-object on a {@code PUT /clients/{id}} behaves symmetrically with
* the create-time inline behaviour. If a KYC row already exists for this client, the editable
* fields are merged in and the status is bumped to IN_PROGRESS (matching the existing
* {@link #updateKyc} semantics). If no row exists yet, it falls back to {@link #initiateKycInline}.
*
* <p>Does NOT trigger PAN / Aadhaar verification — those still go through the dedicated
* verify-* endpoints. This method only keeps the KYC row's static fields in sync.
*/
void upsertKycInline(Client client, String kycJson);
/** Update PAN / Aadhaar / GSTIN details; triggers IN_PROGRESS status. */
CommandProcessingResult updateKyc(Long clientId, Long kycId, JsonCommand command);
@@ -937,6 +937,55 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
kycRepositoryWrapper.saveAndFlush(kyc);
}
@Override
@Transactional
public void upsertKycInline(final Client client, final String kycJson) {
// No row yet → defer to the standard inline create path. This is the case where the user
// is editing a client who never had a KYC row created (e.g. created before KYC was rolled
// out, or KYC was skipped at create time).
if (!kycRepositoryWrapper.existsByClientId(client.getId())) {
LOG.info("upsertKycInline: no KYC row for client {}. Falling back to initiate.", client.getId());
initiateKycInline(client, kycJson);
return;
}
// Row exists → merge editable fields. Validation mirrors the explicit /kyc update endpoint
// so the user gets the same error messages whether they go through edit-client or
// edit-kyc directly.
final ClientKycCommand kycCommand = deserializer.commandFromApiJson(
kycJson, ClientKycApiConstants.UPDATE_REQUEST_PARAMS);
kycCommand.validateForUpdate();
final ClientKyc kyc = kycRepositoryWrapper.findByClientIdWithNotFoundDetection(client.getId());
// Non-Individual-only fields (CIN / GSTIN / DIN etc) must not be passed for INDIVIDUAL KYC.
final boolean isIndividualExisting = kyc.getKycType() != null && kyc.getKycType().isIndividual();
final JsonElement parsedJson = this.fromApiJsonHelper.parse(kycJson);
final JsonCommand jsonCommand = JsonCommand.from(kycJson, parsedJson, this.fromApiJsonHelper,
"CLIENTKYC", null, null, null, client.getId(), null, null, null, null, null, null, null, null, null);
enforceNonIndividualOnlyFields(jsonCommand, isIndividualExisting);
final ClientKycStatus currentStatus = kyc.getKycStatus();
final Map<String, Object> changes = kyc.update(jsonCommand);
if (changes.isEmpty()) {
return; // no-op — edit-client sent the same KYC values that are already on the row
}
// Same audit semantics as the explicit updateKyc endpoint:
// • PAN-edited on a VERIFIED row → reset PAN verification (forces re-verify)
// • Any change moves status to IN_PROGRESS so the row is no longer mistaken for a
// completed KYC until verify-pan / verify-aadhaar run again.
if (currentStatus.isVerified() && changes.containsKey("panNumber")) {
kyc.resetPanVerification();
}
if (!kyc.getKycStatus().isInProgress()) {
kyc.markInProgress();
}
kycRepositoryWrapper.saveAndFlush(kyc);
LOG.info("upsertKycInline: clientId={}, kycId={}, applied {} change(s), newStatus={}",
client.getId(), kyc.getId(), changes.size(), kyc.getKycStatusValue());
}
// ── helpers ───────────────────────────────────────────────────────────────
private CommandProcessingResult buildResult(final JsonCommand command, final Client client,
@@ -1142,6 +1142,21 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
extractAndCreateClientNonPerson(clientForUpdate, command);
}
}
// ── Inline KYC upsert (optional) ─────────────────────────────────
// Mirror of the create-side block at the top of createClient(): if the edit payload
// contains a `kyc` sub-object, hand it to the KYC service so the row is created (if
// missing) or its editable fields are updated (status bumps to IN_PROGRESS).
// Verification still requires the UI to call /verify-pan + /verify-aadhaar.
final JsonElement updateRoot = this.fromApiJsonHelper.parse(command.json());
if (updateRoot.isJsonObject() && updateRoot.getAsJsonObject().has(ClientApiConstants.kycParamName)) {
final JsonElement kycElement = updateRoot.getAsJsonObject().get(ClientApiConstants.kycParamName);
if (kycElement != null && !kycElement.isJsonNull()) {
this.clientKycWritePlatformService.upsertKycInline(clientForUpdate,
this.fromApiJsonHelper.toJson(kycElement));
}
}
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityExternalId(clientForUpdate.getExternalId()) //
@@ -31,4 +31,27 @@ public interface AppUserRepository extends JpaRepository<AppUser, Long>, JpaSpec
AppUser findAppUserByName(@Param("username") String username);
Collection<AppUser> findByOfficeId(Long officeId);
/**
* Case-insensitive email lookup. Used by the createUser flow to surface a clean
* "email already registered" error before hitting the DB unique constraint.
*/
@Query("SELECT u FROM AppUser u WHERE LOWER(u.email) = LOWER(:email)")
java.util.Optional<AppUser> findByEmailIgnoreCase(@Param("email") String email);
/**
* Same as {@link #findByEmailIgnoreCase} but excludes a given user id — used by the updateUser
* flow so a user editing their profile but keeping the same email isn't told it's taken.
*/
@Query("SELECT u FROM AppUser u WHERE LOWER(u.email) = LOWER(:email) AND u.id <> :excludeUserId")
java.util.Optional<AppUser> findByEmailIgnoreCaseAndIdNot(@Param("email") String email,
@Param("excludeUserId") Long excludeUserId);
/** Exact-match mobile lookup — used by the createUser flow's pre-flight unique check. */
java.util.Optional<AppUser> findByMobileNo(String mobileNo);
/** Same as {@link #findByMobileNo} but excludes a given user id, for the update flow. */
@Query("SELECT u FROM AppUser u WHERE u.mobileNo = :mobileNo AND u.id <> :excludeUserId")
java.util.Optional<AppUser> findByMobileNoAndIdNot(@Param("mobileNo") String mobileNo,
@Param("excludeUserId") Long excludeUserId);
}
@@ -0,0 +1,213 @@
/**
* 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.dsc.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.io.IOException;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.useradministration.dsc.data.BankUserDscData;
import org.apache.fineract.useradministration.dsc.data.DscChallengeStatusResponse;
import org.apache.fineract.useradministration.dsc.data.DscEnrollCallbackRequest;
import org.apache.fineract.useradministration.dsc.data.DscEnrollStartResponse;
import org.apache.fineract.useradministration.dsc.service.DscEnrollmentWriteService;
import org.apache.fineract.useradministration.dsc.service.DscReadPlatformService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
/**
* Digital Signature Certificate (DSC) mappings + enrollment lifecycle.
*
* <ul>
* <li>POST /v1/dsc/enroll/start — kicks off enrollment; returns randomNo + jnlpUrl</li>
* <li>GET /v1/dsc/enroll/{randomNo}.jnlp — streams the JNLP that launches the helper</li>
* <li>POST /v1/dsc/enroll/{randomNo}/callback — helper POSTs cert details here</li>
* <li>GET /v1/dsc/enroll/{randomNo}/status — UI polls for completion</li>
* <li>GET /v1/dsc/me — current user's DSC mappings (masked serials)</li>
* <li>GET /v1/dsc/{dscId} — single mapping, unmasked</li>
* <li>DELETE /v1/dsc/{dscId} — soft-revoke</li>
* </ul>
*
* <p>The .jnlp and /callback endpoints are nonce-gated (anyone with the randomNo can hit them).
* Everything else requires the authenticated user.
*/
@Path("/v1/dsc")
@Component
@Tag(name = "DSC", description = "Digital Signature Certificate enrollment and management")
@RequiredArgsConstructor
public class DscApiResource {
private final DscEnrollmentWriteService enrollmentService;
private final DscReadPlatformService readService;
/**
* Filesystem path where the signed helper JAR is dropped. Default lets ops put it next to the
* Fineract install so a redeploy of the .jar (after re-signing with a new cert) doesn't require
* rebuilding the Spring app — just {@code cp} into this path and the next JNLP launch picks it up.
*/
@Value("${fineract.dsc.helper.jar.path:${user.home}/.cbsnexora/dsc-helper.jar}")
private String helperJarPath;
@POST
@Path("/enroll/start")
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Create a DSC enrollment challenge for the current user. Returns a randomNo "
+ "plus the .jnlp URL the UI should trigger.")
public DscEnrollStartResponse start(@Context final HttpServletRequest request) {
final String codebase = deriveCodebase(request);
final String ip = request.getRemoteAddr();
final String ua = request.getHeader("User-Agent");
return enrollmentService.start(codebase, ip, ua);
}
@GET
@Path("/enroll/{randomNo}.jnlp")
@Produces("application/x-java-jnlp-file")
@Operation(summary = "Streams the JNLP file. Nonce-gated; no auth header required so the browser "
+ "can hand the URL directly to OpenWebStart.")
public Response jnlp(@PathParam("randomNo") final String randomNo, @Context final HttpServletRequest request) {
final String codebase = deriveCodebase(request);
final String body = enrollmentService.renderJnlp(randomNo, codebase);
return Response.ok(body, "application/x-java-jnlp-file")
.header("Content-Disposition", "attachment; filename=\"cbsnexora-dsc-enroll.jnlp\"")
.header("Cache-Control", "no-store")
.build();
}
@POST
@Path("/enroll/{randomNo}/callback")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Helper-driven endpoint. Accepts the base64 cert; server parses, validates "
+ "and upserts the m_bank_user_dsc row.")
public DscChallengeStatusResponse callback(@PathParam("randomNo") final String randomNo,
final DscEnrollCallbackRequest req) {
return enrollmentService.callback(randomNo, req);
}
@GET
@Path("/enroll/{randomNo}/status")
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "UI polls this. Returns PENDING until helper completes; then VERIFIED with "
+ "the saved DSC row, or FAILED / EXPIRED with errorMessage.")
public DscChallengeStatusResponse status(@PathParam("randomNo") final String randomNo) {
return enrollmentService.getStatus(randomNo);
}
@GET
@Path("/me")
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Current user's DSC mappings (serial numbers are masked).")
public List<BankUserDscData> listMine() {
return readService.listForCurrentUser();
}
@GET
@Path("/user/{userId}")
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "DSC mappings for an arbitrary user — used by the admin view inside the "
+ "user-details page. Returns the same shape as /me with serials masked. Read-only.")
public List<BankUserDscData> listForUser(@PathParam("userId") final Long userId) {
return readService.listForUser(userId);
}
@GET
@Path("/{dscId}")
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Single DSC mapping (full serial). Returns 404 if not owned by the caller.")
public BankUserDscData getById(@PathParam("dscId") final Long dscId) {
return readService.getById(dscId);
}
@DELETE
@Path("/{dscId}")
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Soft-revokes a DSC mapping. Sets cert_status=REVOKED and is_deleted=true.")
public Response revoke(@PathParam("dscId") final Long dscId, @QueryParam("reason") final String reason) {
readService.revoke(dscId, reason);
return Response.noContent().build();
}
@GET
@Path("/helper.jar")
@Produces("application/java-archive")
@Operation(summary = "Streams the (self-)signed Java helper JAR referenced by the JNLP. "
+ "Public — Java Web Start fetches without an auth header. The JAR file itself is "
+ "code-signed; that's what the JVM enforces, not Fineract auth.")
public Response helperJar() {
final java.nio.file.Path jar = java.nio.file.Path.of(helperJarPath);
if (!java.nio.file.Files.isRegularFile(jar) || !java.nio.file.Files.isReadable(jar)) {
throw new NotFoundException("DSC helper JAR not deployed at " + helperJarPath);
}
final byte[] bytes;
try {
bytes = java.nio.file.Files.readAllBytes(jar);
} catch (final IOException e) {
throw new RuntimeException("Failed to read DSC helper JAR", e);
}
return Response.ok(bytes, "application/java-archive")
.header("Content-Disposition", "attachment; filename=\"cbsnexora-dsc-helper.jar\"")
.header("Cache-Control", "no-store")
.build();
}
/**
* Builds the public base URL the JNLP / helper should call back into. Honours
* {@code X-Forwarded-Proto} / {@code X-Forwarded-Host} so reverse-proxied deployments resolve
* the external hostname instead of the internal one. Includes the servlet context path because
* Fineract is typically mounted at {@code /fineract-provider}.
*/
private static String deriveCodebase(final HttpServletRequest request) {
final String fwdProto = request.getHeader("X-Forwarded-Proto");
final String fwdHost = request.getHeader("X-Forwarded-Host");
final String scheme = fwdProto != null && !fwdProto.isBlank() ? fwdProto : request.getScheme();
final String host;
final int port;
if (fwdHost != null && !fwdHost.isBlank()) {
final int colon = fwdHost.indexOf(':');
host = colon < 0 ? fwdHost : fwdHost.substring(0, colon);
port = colon < 0 ? -1 : Integer.parseInt(fwdHost.substring(colon + 1));
} else {
host = request.getServerName();
port = request.getServerPort();
}
final StringBuilder sb = new StringBuilder(scheme).append("://").append(host);
final boolean defaultPort = ("https".equalsIgnoreCase(scheme) && port == 443)
|| ("http".equalsIgnoreCase(scheme) && port == 80) || port < 0;
if (!defaultPort) {
sb.append(':').append(port);
}
sb.append(request.getContextPath());
return sb.toString();
}
}
@@ -0,0 +1,140 @@
/**
* 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.dsc.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.useradministration.dsc.data.DscLoginChallengeStartResponse;
import org.apache.fineract.useradministration.dsc.data.DscLoginStatusResponse;
import org.apache.fineract.useradministration.dsc.data.DscLoginVerifyRequest;
import org.apache.fineract.useradministration.dsc.service.DscLoginChallengeService;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
/**
* DSC-based login factor. After primary basic-auth succeeds, a user who has an enrolled DSC may
* pick the DSC option instead of SMS/EMAIL. The flow:
*
* <ul>
* <li>POST /v1/login/dsc/challenge — create challenge (requires basic auth, NOT TFA)</li>
* <li>GET /v1/login/dsc/{rn}.jnlp — JNLP for the helper (nonce-gated)</li>
* <li>POST /v1/login/dsc/{rn}/verify — helper POSTs signature (nonce-gated)</li>
* <li>GET /v1/login/dsc/{rn}/status — UI polls (nonce-gated)</li>
* </ul>
*
* <p>On successful verify, the response includes a {@code tfaToken} the UI immediately puts into
* the {@code Fineract-Platform-TFA-Token} header. Status polling never echoes the token back —
* the verify response is its only emission.
*/
@Path("/v1/login/dsc")
@Component
@ConditionalOnProperty("fineract.security.2fa.enabled")
@Tag(name = "Login DSC", description = "DSC-based two-factor authentication")
@RequiredArgsConstructor
public class LoginDscApiResource {
private final DscLoginChallengeService loginChallengeService;
@POST
@Path("/challenge")
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Create a DSC login challenge for the basic-authenticated user. Returns "
+ "randomNo + jnlpUrl + statusUrl. Caller does NOT need TWOFACTOR_AUTHENTICATED.")
public DscLoginChallengeStartResponse challenge(@Context final HttpServletRequest request) {
return loginChallengeService.start(deriveCodebase(request), request.getRemoteAddr(), request.getHeader("User-Agent"));
}
@GET
@Path("/{randomNo}.jnlp")
@Produces("application/x-java-jnlp-file")
@Operation(summary = "Streams the JNLP that launches the helper in SIGN_CHALLENGE mode.")
public Response jnlp(@PathParam("randomNo") final String randomNo, @Context final HttpServletRequest request) {
final String codebase = deriveCodebase(request);
final String body = loginChallengeService.renderJnlp(randomNo, codebase);
return Response.ok(body, "application/x-java-jnlp-file")
.header("Content-Disposition", "attachment; filename=\"cbsnexora-dsc-login.jnlp\"")
.header("Cache-Control", "no-store")
.build();
}
@POST
@Path("/{randomNo}/verify")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Helper POSTs cert serial + signature. Server verifies, then mints a TFA "
+ "access token returned in the response.")
public DscLoginStatusResponse verify(@PathParam("randomNo") final String randomNo, final DscLoginVerifyRequest req) {
return loginChallengeService.verify(randomNo, req);
}
@GET
@Path("/{randomNo}/status")
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "UI polls this. Returns PENDING/VERIFIED/FAILED/EXPIRED. Never echoes the "
+ "TFA token — the browser fetches it via /claim once status flips to VERIFIED.")
public DscLoginStatusResponse status(@PathParam("randomNo") final String randomNo) {
return loginChallengeService.getStatus(randomNo);
}
@POST
@Path("/{randomNo}/claim")
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Browser claims the minted TFA token after seeing status=VERIFIED. "
+ "Requires basic auth (caller must own the challenge). Single-use — the stored token "
+ "is cleared after this returns.")
public DscLoginStatusResponse claim(@PathParam("randomNo") final String randomNo) {
return loginChallengeService.claim(randomNo);
}
/** Same codebase resolution as {@link DscApiResource} — honours reverse-proxy headers. */
private static String deriveCodebase(final HttpServletRequest request) {
final String fwdProto = request.getHeader("X-Forwarded-Proto");
final String fwdHost = request.getHeader("X-Forwarded-Host");
final String scheme = fwdProto != null && !fwdProto.isBlank() ? fwdProto : request.getScheme();
final String host;
final int port;
if (fwdHost != null && !fwdHost.isBlank()) {
final int colon = fwdHost.indexOf(':');
host = colon < 0 ? fwdHost : fwdHost.substring(0, colon);
port = colon < 0 ? -1 : Integer.parseInt(fwdHost.substring(colon + 1));
} else {
host = request.getServerName();
port = request.getServerPort();
}
final StringBuilder sb = new StringBuilder(scheme).append("://").append(host);
final boolean defaultPort = ("https".equalsIgnoreCase(scheme) && port == 443)
|| ("http".equalsIgnoreCase(scheme) && port == 80) || port < 0;
if (!defaultPort) {
sb.append(':').append(port);
}
sb.append(request.getContextPath());
return sb.toString();
}
}
@@ -0,0 +1,98 @@
/**
* 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.dsc.data;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import lombok.Builder;
import lombok.Getter;
import org.apache.fineract.useradministration.dsc.domain.BankUserDsc;
import org.apache.fineract.useradministration.dsc.domain.CertStatus;
/**
* Wire representation of a row in {@code m_bank_user_dsc}. Public key is intentionally omitted
* from the default view — only the cert metadata the UI needs to display. Serial is sent masked
* (last 6 chars visible) so list views don't leak full serials.
*/
@Getter
@Builder
public class BankUserDscData {
private final Long id;
private final Long appUserId;
private final String username;
private final String dscName;
private final String dscSerialNoMasked;
private final String dscSerialNoFull;
private final String dscType;
private final String commonName;
private final String issuedTo;
private final String issuedBy;
private final LocalDate dscExpiryDate;
private final OffsetDateTime dateOfSubmission;
private final String certStatus;
private final OffsetDateTime revokedOn;
private final String revokeReason;
/** {@code maskSerial = true} for list views, false for the single-row detail endpoint. */
public static BankUserDscData from(final BankUserDsc d, final boolean maskSerial) {
return BankUserDscData.builder()
.id(d.getId())
.appUserId(d.getAppUser() != null ? d.getAppUser().getId() : null)
.username(d.getAppUser() != null ? d.getAppUser().getUsername() : null)
.dscName(d.getDscName())
.dscSerialNoMasked(mask(d.getDscSerialNo()))
.dscSerialNoFull(maskSerial ? null : d.getDscSerialNo())
.dscType(d.getDscType() != null ? d.getDscType().name() : null)
.commonName(d.getCommonName())
.issuedTo(d.getIssuedTo())
.issuedBy(d.getIssuedBy())
.dscExpiryDate(d.getDscExpiryDate())
.dateOfSubmission(d.getDateOfSubmission())
.certStatus(effectiveStatus(d))
.revokedOn(d.getRevokedOn())
.revokeReason(d.getRevokeReason())
.build();
}
/**
* Stored {@code cert_status} is authoritative for REVOKED, but ACTIVE rows whose
* {@code dscExpiryDate} has passed get reported as EXPIRED on the wire. The DB row stays
* ACTIVE until a maintenance sweep flips it, but the UI never sees a stale ACTIVE for an
* expired cert.
*/
private static String effectiveStatus(final BankUserDsc d) {
final CertStatus stored = d.getCertStatus();
if (stored == null) {
return null;
}
if (stored == CertStatus.ACTIVE && d.getDscExpiryDate() != null
&& d.getDscExpiryDate().isBefore(LocalDate.now())) {
return CertStatus.EXPIRED.name();
}
return stored.name();
}
private static String mask(final String s) {
if (s == null || s.length() <= 6) {
return s;
}
return "•".repeat(Math.min(s.length() - 6, 12)) + s.substring(s.length() - 6);
}
}
@@ -0,0 +1,36 @@
/**
* 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.dsc.data;
import lombok.Builder;
import lombok.Getter;
/**
* UI polls for this. {@code status} is {@code PENDING} until the helper completes, then flips to
* {@code VERIFIED} (with {@code dsc} populated on enrollment success) or {@code FAILED}/{@code EXPIRED}.
*/
@Getter
@Builder
public class DscChallengeStatusResponse {
private final String randomNo;
private final String status;
private final String errorMessage;
private final BankUserDscData dsc;
}
@@ -0,0 +1,45 @@
/**
* 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.dsc.data;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* Body the Java helper POSTs back to {@code /v1/dsc/enroll/{randomNo}/callback}.
*
* <p>{@link #certB64} is the base64-encoded X.509 DER cert — server parses it as the source of
* truth for serial, issuer, subject, notAfter, and the public key. {@link #dscName} is an
* optional user-provided label.
*/
@Getter
@Setter
@NoArgsConstructor
public class DscEnrollCallbackRequest {
/** Base64-encoded X.509 DER bytes. */
private String certB64;
/** Optional user-facing label, e.g. "Aditya's Primary Token". */
private String dscName;
/** Optional — SIGNING or ENCRYPTION. Defaults to SIGNING when null. */
private String dscType;
}
@@ -0,0 +1,52 @@
/**
* 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.dsc.data;
import lombok.Builder;
import lombok.Getter;
/**
* Returned to the UI when an enrollment cycle is created. The UI uses {@code jnlpUrl} to trigger
* the browser download that launches the helper; it then polls {@code statusUrl} for completion.
*/
@Getter
@Builder
public class DscEnrollStartResponse {
private final String randomNo;
private final String jnlpUrl;
private final String statusUrl;
private final int expiresInSeconds;
/**
* Pre-stringified JSON the helper expects in its single launch argument. The UI embeds this
* verbatim into a locally-generated JNLP file's {@code <argument>} element, so JWS never has
* to fetch a JNLP from the server.
*
* <p>Includes the absolute {@code callbackUrl} the helper POSTs back to + the
* {@code tenantIdentifier} the helper uses as a request header.
*/
private final String helperArgsJson;
/**
* Relative URL the UI XHRs to fetch the helper JAR bytes. The interceptor adds the auth
* + tenant headers so the request goes through normal channels — JWS is never involved.
*/
private final String helperJarUrl;
}
@@ -0,0 +1,38 @@
/**
* 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.dsc.data;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public class DscLoginChallengeStartResponse {
private final String randomNo;
private final String jnlpUrl;
private final String statusUrl;
private final int expiresInSeconds;
/** See {@link DscEnrollStartResponse#getHelperArgsJson()}. Same shape for the login flow. */
private final String helperArgsJson;
/** See {@link DscEnrollStartResponse#getHelperJarUrl()}. */
private final String helperJarUrl;
}
@@ -0,0 +1,38 @@
/**
* 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.dsc.data;
import lombok.Builder;
import lombok.Getter;
/**
* Polled by the login UI. On {@code VERIFIED}, {@link #tfaToken} carries the {@code TFAccessToken}
* the UI puts in the {@code Fineract-Platform-TFA-Token} header for subsequent calls — same shape
* as what {@code /v1/twofactor/validate} returns for OTP-based flows.
*/
@Getter
@Builder
public class DscLoginStatusResponse {
private final String randomNo;
private final String status;
private final String errorMessage;
private final String tfaToken;
private final Long tfaTokenLiveSeconds;
}
@@ -0,0 +1,39 @@
/**
* 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.dsc.data;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* Body the Java helper POSTs to {@code /v1/login/dsc/{randomNo}/verify}. Helper-driven, no Fineract
* auth header — the {@link #certSerial} + signature pair authenticates the user.
*/
@Getter
@Setter
@NoArgsConstructor
public class DscLoginVerifyRequest {
/** Hex serial of the DSC the user signed with — must match the user's enrolled mapping. */
private String certSerial;
/** Base64-encoded RSA-SHA256 signature over the challenge bytes. */
private String signatureB64;
}
@@ -0,0 +1,32 @@
/**
* 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.dsc.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
/**
* Thrown when a DSC challenge row is in a non-PENDING state — already verified, failed, or
* expired — and a callback tries to use it again. Single-shot enforcement.
*/
public class DscChallengeInvalidException extends AbstractPlatformDomainRuleException {
public DscChallengeInvalidException(final String reason) {
super("error.msg.dsc.challenge.invalid", reason, reason);
}
}
@@ -0,0 +1,28 @@
/**
* 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.dsc.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
public class DscChallengeNotFoundException extends AbstractPlatformResourceNotFoundException {
public DscChallengeNotFoundException(final String randomNo) {
super("error.msg.dsc.challenge.not.found", "DSC challenge " + randomNo + " not found", randomNo);
}
}
@@ -0,0 +1,28 @@
/**
* 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.dsc.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
public class DscNotFoundException extends AbstractPlatformResourceNotFoundException {
public DscNotFoundException(final Long dscId) {
super("error.msg.dsc.not.found", "DSC mapping " + dscId + " not found", dscId);
}
}
@@ -0,0 +1,34 @@
/**
* 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.dsc.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
/**
* The DSC serial is globally unique — one cert in the world has one serial. If a user tries to
* enroll a cert already bound to a different appuser, we reject without revealing which user owns
* it.
*/
public class DscSerialAlreadyEnrolledException extends AbstractPlatformDomainRuleException {
public DscSerialAlreadyEnrolledException(final String serial) {
super("error.msg.dsc.serial.already.enrolled",
"This Digital Signature Certificate is already enrolled in the system", serial);
}
}
@@ -0,0 +1,34 @@
/**
* 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.dsc.service;
import org.apache.fineract.useradministration.dsc.data.DscChallengeStatusResponse;
import org.apache.fineract.useradministration.dsc.data.DscEnrollCallbackRequest;
import org.apache.fineract.useradministration.dsc.data.DscEnrollStartResponse;
public interface DscEnrollmentWriteService {
DscEnrollStartResponse start(String codebase, String ipAddress, String userAgent);
String renderJnlp(String randomNo, String codebase);
DscChallengeStatusResponse callback(String randomNo, DscEnrollCallbackRequest request);
DscChallengeStatusResponse getStatus(String randomNo);
}
@@ -0,0 +1,255 @@
/**
* 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.dsc.service;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.Optional;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.domain.AppUser;
import org.apache.fineract.useradministration.dsc.data.BankUserDscData;
import org.apache.fineract.useradministration.dsc.data.DscChallengeStatusResponse;
import org.apache.fineract.useradministration.dsc.data.DscEnrollCallbackRequest;
import org.apache.fineract.useradministration.dsc.data.DscEnrollStartResponse;
import org.apache.fineract.useradministration.dsc.domain.BankUserDsc;
import org.apache.fineract.useradministration.dsc.domain.BankUserDscRepository;
import org.apache.fineract.useradministration.dsc.domain.CertStatus;
import org.apache.fineract.useradministration.dsc.domain.DscChallenge;
import org.apache.fineract.useradministration.dsc.domain.DscChallengePurpose;
import org.apache.fineract.useradministration.dsc.domain.DscChallengeRepository;
import org.apache.fineract.useradministration.dsc.domain.DscChallengeStatus;
import org.apache.fineract.useradministration.dsc.domain.DscType;
import org.apache.fineract.useradministration.dsc.exception.DscChallengeInvalidException;
import org.apache.fineract.useradministration.dsc.exception.DscChallengeNotFoundException;
import org.apache.fineract.useradministration.dsc.exception.DscSerialAlreadyEnrolledException;
import org.apache.fineract.useradministration.dsc.util.X509CertParser;
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;
@Service
@RequiredArgsConstructor
public class DscEnrollmentWriteServiceImpl implements DscEnrollmentWriteService {
private static final Logger LOG = LoggerFactory.getLogger(DscEnrollmentWriteServiceImpl.class);
private final PlatformSecurityContext securityContext;
private final DscChallengeRepository challengeRepository;
private final BankUserDscRepository dscRepository;
private final X509CertParser certParser;
private final DscJnlpGenerator jnlpGenerator;
@Value("${fineract.dsc.challenge.ttl-seconds:300}")
private int challengeTtlSeconds;
@Override
@Transactional
public DscEnrollStartResponse start(final String codebase, final String ipAddress, final String userAgent) {
final AppUser user = securityContext.authenticatedUser();
final String randomNo = UUID.randomUUID().toString();
final DscChallenge ch = new DscChallenge();
ch.setRandomNo(randomNo);
ch.setAppUser(user);
ch.setPurpose(DscChallengePurpose.ENROLL);
// ENROLL flow doesn't sign anything — challenge column is just a placeholder marker.
ch.setChallenge("ENROLL");
ch.setStatus(DscChallengeStatus.PENDING);
ch.setExpiresAt(OffsetDateTime.now(ZoneOffset.UTC).plusSeconds(challengeTtlSeconds));
ch.setIpAddress(ipAddress);
ch.setUserAgent(userAgent);
challengeRepository.saveAndFlush(ch);
LOG.info("DSC enroll started — user={}, randomNo={}, ttl={}s", user.getUsername(), randomNo, challengeTtlSeconds);
// Args the helper expects in its launch argument. The UI embeds this into a JNLP it
// generates locally — JWS never fetches a JNLP from the server, only reads it from disk.
final java.util.Map<String, Object> helperArgs = new java.util.LinkedHashMap<>();
helperArgs.put("randomNo", randomNo);
helperArgs.put("mode", "ENROLL");
helperArgs.put("callbackUrl", codebase + "/api/v1/dsc/enroll/" + randomNo + "/callback");
helperArgs.put("title", "Map Digital Signature Certificate");
final String tenant = currentTenantIdentifier();
if (tenant != null) {
helperArgs.put("tenantIdentifier", tenant);
}
final String helperArgsJson = new com.google.gson.Gson().toJson(helperArgs);
return DscEnrollStartResponse.builder()
.randomNo(randomNo)
.jnlpUrl("/v1/dsc/enroll/" + randomNo + ".jnlp")
.statusUrl("/v1/dsc/enroll/" + randomNo + "/status")
.expiresInSeconds(challengeTtlSeconds)
.helperArgsJson(helperArgsJson)
.helperJarUrl("/v1/dsc/helper.jar")
.build();
}
@Override
@Transactional(readOnly = true)
public String renderJnlp(final String randomNo, final String codebase) {
final DscChallenge ch = challengeRepository.findByRandomNo(randomNo)
.orElseThrow(() -> new DscChallengeNotFoundException(randomNo));
if (ch.getStatus() != DscChallengeStatus.PENDING || ch.isExpired()) {
throw new DscChallengeInvalidException("Challenge is no longer accepting helper launches");
}
final DscJnlpGenerator.JnlpContext ctx = new DscJnlpGenerator.JnlpContext();
ctx.randomNo = randomNo;
ctx.mode = "ENROLL";
ctx.callbackUrl = codebase + "/api/v1/dsc/enroll/" + randomNo + "/callback";
ctx.codebase = codebase;
ctx.tenantIdentifier = currentTenantIdentifier();
ctx.title = "Map Digital Signature Certificate";
ctx.description = "Reads your DSC and maps it to your CBSnexora user account.";
return jnlpGenerator.generate(ctx);
}
/**
* Tenant identifier the request is running under — resolved by {@code TenantAwareBasicAuthentication
* Filter} earlier in the chain and stashed in the thread-local context. We embed this in the
* JNLP so JWS's raw GET for the helper JAR can resolve the tenant via {@code ?tenantIdentifier=}
* (the filter already accepts that as a fallback to the header) and so the helper itself can
* set the {@code Fineract-Platform-TenantId} header on its callback POST.
*/
private String currentTenantIdentifier() {
try {
return org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil.getTenant().getTenantIdentifier();
} catch (final Exception e) {
LOG.warn("Could not resolve current tenant for JNLP — JWS / helper may fail without ?tenantIdentifier", e);
return null;
}
}
@Override
@Transactional
public DscChallengeStatusResponse callback(final String randomNo, final DscEnrollCallbackRequest req) {
final DscChallenge ch = challengeRepository.findByRandomNo(randomNo)
.orElseThrow(() -> new DscChallengeNotFoundException(randomNo));
if (ch.getStatus() != DscChallengeStatus.PENDING) {
throw new DscChallengeInvalidException("Challenge already terminal: " + ch.getStatus());
}
if (ch.isExpired()) {
ch.setStatus(DscChallengeStatus.EXPIRED);
challengeRepository.saveAndFlush(ch);
throw new DscChallengeInvalidException("Challenge expired");
}
if (req == null || req.getCertB64() == null || req.getCertB64().isBlank()) {
failChallenge(ch, "certB64 missing");
throw new DscChallengeInvalidException("certB64 is required");
}
final X509CertParser.ParsedCert parsed;
try {
parsed = certParser.parse(req.getCertB64());
} catch (final IllegalArgumentException e) {
failChallenge(ch, "cert parse error: " + e.getMessage());
throw new DscChallengeInvalidException(e.getMessage());
}
// Globally unique — if this serial is already bound to a different user, refuse without
// revealing who owns it.
final Optional<BankUserDsc> existing = dscRepository.findByDscSerialNo(parsed.getSerial());
final AppUser owner = ch.getAppUser();
if (existing.isPresent() && !existing.get().getAppUser().getId().equals(owner.getId())) {
failChallenge(ch, "serial already enrolled");
throw new DscSerialAlreadyEnrolledException(parsed.getSerial());
}
// Upsert: re-enrolling the same serial for the same user refreshes name/pubkey/expiry and
// resurrects a soft-revoked row.
final BankUserDsc dsc = existing.orElseGet(BankUserDsc::new);
dsc.setAppUser(owner);
dsc.setDscUserId(owner.getUsername());
dsc.setDscSerialNo(parsed.getSerial());
dsc.setDscName(req.getDscName() != null && !req.getDscName().isBlank() ? req.getDscName() : parsed.getCommonName());
dsc.setDscPublicKey(parsed.getPublicKeyB64());
dsc.setDscExpiryDate(parsed.getNotAfter());
dsc.setDateOfSubmission(OffsetDateTime.now(ZoneOffset.UTC));
dsc.setDscType(parseTypeOrSigning(req.getDscType()));
dsc.setCommonName(parsed.getCommonName());
dsc.setIssuedTo(parsed.getSubjectDn());
dsc.setIssuedBy(parsed.getIssuerDn());
dsc.setCertStatus(CertStatus.ACTIVE);
dsc.setActive(true);
dsc.setDeleted(false);
dsc.setRevokedOn(null);
dsc.setRevokedBy(null);
dsc.setRevokeReason(null);
if (dsc.getCreatedBy() == null) {
dsc.setCreatedBy(owner.getId());
} else {
dsc.setModifiedBy(owner.getId());
dsc.setModifiedOn(OffsetDateTime.now(ZoneOffset.UTC));
}
final BankUserDsc saved = dscRepository.saveAndFlush(dsc);
ch.setStatus(DscChallengeStatus.VERIFIED);
ch.setVerifiedAt(OffsetDateTime.now(ZoneOffset.UTC));
ch.setCertSerialReturned(parsed.getSerial());
ch.setDscId(saved.getId());
challengeRepository.saveAndFlush(ch);
LOG.info("DSC enrolled — user={}, dscId={}, serial={}", owner.getUsername(), saved.getId(), parsed.getSerial());
return DscChallengeStatusResponse.builder()
.randomNo(randomNo)
.status(DscChallengeStatus.VERIFIED.name())
.dsc(BankUserDscData.from(saved, false))
.build();
}
@Override
@Transactional(readOnly = true)
public DscChallengeStatusResponse getStatus(final String randomNo) {
final DscChallenge ch = challengeRepository.findByRandomNo(randomNo)
.orElseThrow(() -> new DscChallengeNotFoundException(randomNo));
BankUserDscData dscData = null;
if (ch.getStatus() == DscChallengeStatus.VERIFIED && ch.getDscId() != null) {
dscData = dscRepository.findById(ch.getDscId())
.map(d -> BankUserDscData.from(d, false))
.orElse(null);
}
return DscChallengeStatusResponse.builder()
.randomNo(randomNo)
.status(ch.getStatus().name())
.errorMessage(ch.getErrorMessage())
.dsc(dscData)
.build();
}
private void failChallenge(final DscChallenge ch, final String reason) {
ch.setStatus(DscChallengeStatus.FAILED);
ch.setErrorMessage(reason);
ch.setAttempts(ch.getAttempts() + 1);
challengeRepository.saveAndFlush(ch);
LOG.info("DSC enroll callback failed — randomNo={}, reason={}", ch.getRandomNo(), reason);
}
private static DscType parseTypeOrSigning(final String s) {
final DscType t = DscType.fromString(s);
return t != null ? t : DscType.SIGNING;
}
}
@@ -0,0 +1,125 @@
/**
* 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.dsc.service;
import com.google.gson.Gson;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
/**
* Builds the JNLP XML the browser hands to Java Web Start / OpenWebStart. The helper JAR is
* downloaded fresh on every launch (no caching) so a server-side helper upgrade rolls out
* immediately.
*
* <p>Argument shape passed to {@code CbsnexoraDscHelper.main()} is a single JSON object — the
* helper parses it with Gson and dispatches on {@code mode}.
*/
@Component
public class DscJnlpGenerator {
private static final Gson GSON = new Gson();
@Value("${fineract.dsc.helper.jar.url:/api/v1/dsc/helper.jar}")
private String helperJarUrl;
@Value("${fineract.dsc.helper.main-class:com.cbsnexora.dsc.CbsnexoraDscHelper}")
private String mainClass;
@Value("${fineract.dsc.helper.vendor:CBSnexora}")
private String vendor;
public String generate(final JnlpContext ctx) {
final Map<String, Object> args = new LinkedHashMap<>();
args.put("randomNo", ctx.randomNo);
args.put("mode", ctx.mode);
args.put("callbackUrl", ctx.callbackUrl);
if (ctx.challenge != null) {
args.put("challenge", ctx.challenge);
}
if (ctx.expectedSerial != null) {
args.put("expectedSerial", ctx.expectedSerial);
}
if (ctx.tenantIdentifier != null) {
// Helper reads this and adds `Fineract-Platform-TenantId: <value>` to its callback POST,
// because the helper isn't routed through the UI's auth interceptor — it makes raw
// HttpURLConnection calls.
args.put("tenantIdentifier", ctx.tenantIdentifier);
}
args.put("title", ctx.title);
final String argJson = escapeForXml(GSON.toJson(args));
// JWS will GET the JAR with no headers (it's a fresh process, not the browser). Embedding
// `?tenantIdentifier=` so Fineract's tenant filter (which already falls back to the query
// param when the header is absent) can resolve the tenant for this raw request.
final String jarUrlWithTenant = ctx.tenantIdentifier != null
? appendQueryParam(helperJarUrl, "tenantIdentifier", ctx.tenantIdentifier)
: helperJarUrl;
final String absoluteJarUrl = jarUrlWithTenant.startsWith("http")
? jarUrlWithTenant
: ctx.codebase + jarUrlWithTenant;
return "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n"
+ "<jnlp spec=\"1.0+\" codebase=\"" + ctx.codebase + "\">\n"
+ " <information>\n"
+ " <vendor>" + escapeForXml(vendor) + "</vendor>\n"
+ " <title>" + escapeForXml(ctx.title) + "</title>\n"
+ " <description>" + escapeForXml(ctx.description) + "</description>\n"
+ " </information>\n"
+ " <security>\n"
+ " <all-permissions/>\n"
+ " </security>\n"
+ " <resources>\n"
+ " <j2se version=\"1.8+\"/>\n"
+ " <jar href=\"" + absoluteJarUrl + "\"/>\n"
+ " </resources>\n"
+ " <application-desc main-class=\"" + mainClass + "\">\n"
+ " <argument>" + argJson + "</argument>\n"
+ " </application-desc>\n"
+ "</jnlp>";
}
private static String escapeForXml(final String s) {
if (s == null) {
return "";
}
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;").replace("'", "&apos;");
}
private static String appendQueryParam(final String url, final String name, final String value) {
if (url == null || name == null || value == null) {
return url;
}
final String sep = url.indexOf('?') >= 0 ? "&" : "?";
return url + sep + name + "=" + java.net.URLEncoder.encode(value, java.nio.charset.StandardCharsets.UTF_8);
}
public static class JnlpContext {
public String randomNo;
public String mode; // ENROLL | SIGN_CHALLENGE
public String callbackUrl;
public String challenge; // only for SIGN_CHALLENGE
public String expectedSerial; // only for SIGN_CHALLENGE — helper enforces user picks the right cert
public String codebase; // e.g. https://cbs.example.com/fineract-provider
public String tenantIdentifier; // embedded in helper.jar URL + args; helper uses it for callback POST header
public String title;
public String description;
}
}
@@ -0,0 +1,41 @@
/**
* 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.dsc.service;
import org.apache.fineract.useradministration.dsc.data.DscLoginChallengeStartResponse;
import org.apache.fineract.useradministration.dsc.data.DscLoginStatusResponse;
import org.apache.fineract.useradministration.dsc.data.DscLoginVerifyRequest;
public interface DscLoginChallengeService {
DscLoginChallengeStartResponse start(String codebase, String ipAddress, String userAgent);
String renderJnlp(String randomNo, String codebase);
DscLoginStatusResponse verify(String randomNo, DscLoginVerifyRequest request);
DscLoginStatusResponse getStatus(String randomNo);
/**
* Browser-side, basic-authed claim of the minted TFA token. Single-use — once claimed, the
* stored token is cleared. Caller must own the challenge (i.e. be the same user the helper
* verified for); otherwise treated as not-found to prevent enumeration.
*/
DscLoginStatusResponse claim(String randomNo);
}
@@ -0,0 +1,282 @@
/**
* 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.dsc.service;
import java.security.SecureRandom;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.HexFormat;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.domain.TFAccessToken;
import org.apache.fineract.infrastructure.security.domain.TFAccessTokenRepository;
import org.apache.fineract.infrastructure.security.service.AccessTokenGenerationService;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.infrastructure.security.service.TwoFactorConfigurationService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.apache.fineract.useradministration.dsc.data.DscLoginChallengeStartResponse;
import org.apache.fineract.useradministration.dsc.data.DscLoginStatusResponse;
import org.apache.fineract.useradministration.dsc.data.DscLoginVerifyRequest;
import org.apache.fineract.useradministration.dsc.domain.BankUserDsc;
import org.apache.fineract.useradministration.dsc.domain.BankUserDscRepository;
import org.apache.fineract.useradministration.dsc.domain.DscChallenge;
import org.apache.fineract.useradministration.dsc.domain.DscChallengePurpose;
import org.apache.fineract.useradministration.dsc.domain.DscChallengeRepository;
import org.apache.fineract.useradministration.dsc.domain.DscChallengeStatus;
import org.apache.fineract.useradministration.dsc.exception.DscChallengeInvalidException;
import org.apache.fineract.useradministration.dsc.exception.DscChallengeNotFoundException;
import org.apache.fineract.useradministration.dsc.util.DscSignatureVerifier;
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;
/**
* Drives the DSC-based login factor:
*
* <ol>
* <li>{@link #start} — partially-authenticated user (basic auth, no TFA yet) creates a challenge
* row containing a random hex string. UI renders the JNLP.</li>
* <li>{@link #verify} — helper POSTs signature; server checks the user has the cert serial
* enrolled and that RSA-SHA256({challenge}) matches the stored public key. On success a
* {@link TFAccessToken} is minted — same shape as OTP-based 2FA — and returned via status.</li>
* <li>{@link #getStatus} — UI polls; on VERIFIED the response carries the TFA token.</li>
* </ol>
*/
@Service
@RequiredArgsConstructor
public class DscLoginChallengeServiceImpl implements DscLoginChallengeService {
private static final Logger LOG = LoggerFactory.getLogger(DscLoginChallengeServiceImpl.class);
private static final int CHALLENGE_BYTES = 32; // 256 bits → 64 hex chars
private final SecureRandom secureRandom = new SecureRandom();
private final PlatformSecurityContext securityContext;
private final DscChallengeRepository challengeRepository;
private final BankUserDscRepository dscRepository;
private final DscSignatureVerifier signatureVerifier;
private final DscJnlpGenerator jnlpGenerator;
private final AccessTokenGenerationService accessTokenGenerationService;
private final TFAccessTokenRepository tfAccessTokenRepository;
private final TwoFactorConfigurationService twoFactorConfigurationService;
@Value("${fineract.dsc.challenge.ttl-seconds:300}")
private int challengeTtlSeconds;
@Override
@Transactional
public DscLoginChallengeStartResponse start(final String codebase, final String ipAddress, final String userAgent) {
final AppUser user = securityContext.authenticatedUser();
final String randomNo = UUID.randomUUID().toString();
final String challenge = newChallengeHex();
final DscChallenge ch = new DscChallenge();
ch.setRandomNo(randomNo);
ch.setAppUser(user);
ch.setPurpose(DscChallengePurpose.LOGIN_2FA);
ch.setChallenge(challenge);
ch.setStatus(DscChallengeStatus.PENDING);
ch.setExpiresAt(OffsetDateTime.now(ZoneOffset.UTC).plusSeconds(challengeTtlSeconds));
ch.setIpAddress(ipAddress);
ch.setUserAgent(userAgent);
challengeRepository.saveAndFlush(ch);
LOG.info("DSC login challenge started — user={}, randomNo={}", user.getUsername(), randomNo);
// Helper args for the UI to embed into a locally-built JNLP. The helper POSTs to the
// absolute callbackUrl and sets the tenant header from `tenantIdentifier`.
final java.util.Map<String, Object> helperArgs = new java.util.LinkedHashMap<>();
helperArgs.put("randomNo", randomNo);
helperArgs.put("mode", "SIGN_CHALLENGE");
helperArgs.put("callbackUrl", codebase + "/api/v1/login/dsc/" + randomNo + "/verify");
helperArgs.put("challenge", challenge);
helperArgs.put("title", "Verify Digital Signature");
try {
final String tenant = org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil
.getTenant().getTenantIdentifier();
if (tenant != null) {
helperArgs.put("tenantIdentifier", tenant);
}
} catch (final Exception ignored) {
// single-tenant deploys without tenant context — leave out
}
final String helperArgsJson = new com.google.gson.Gson().toJson(helperArgs);
return DscLoginChallengeStartResponse.builder()
.randomNo(randomNo)
.jnlpUrl("/v1/login/dsc/" + randomNo + ".jnlp")
.statusUrl("/v1/login/dsc/" + randomNo + "/status")
.expiresInSeconds(challengeTtlSeconds)
.helperArgsJson(helperArgsJson)
.helperJarUrl("/v1/dsc/helper.jar")
.build();
}
@Override
@Transactional(readOnly = true)
public String renderJnlp(final String randomNo, final String codebase) {
final DscChallenge ch = challengeRepository.findByRandomNo(randomNo)
.orElseThrow(() -> new DscChallengeNotFoundException(randomNo));
if (ch.getStatus() != DscChallengeStatus.PENDING || ch.isExpired()) {
throw new DscChallengeInvalidException("Challenge is no longer accepting helper launches");
}
final DscJnlpGenerator.JnlpContext ctx = new DscJnlpGenerator.JnlpContext();
ctx.randomNo = randomNo;
ctx.mode = "SIGN_CHALLENGE";
ctx.challenge = ch.getChallenge();
ctx.callbackUrl = codebase + "/api/v1/login/dsc/" + randomNo + "/verify";
ctx.codebase = codebase;
// Embed tenant so JWS's headerless GET for helper.jar resolves via the filter's
// ?tenantIdentifier= fallback, and so the helper can put the same value into the
// Fineract-Platform-TenantId header when it POSTs /verify.
try {
ctx.tenantIdentifier = org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil
.getTenant().getTenantIdentifier();
} catch (final Exception ignored) {
// single-tenant deploys may not have a tenant in context — leave null
}
ctx.title = "Verify Digital Signature";
ctx.description = "Signs a login challenge with your enrolled DSC to complete two-factor authentication.";
return jnlpGenerator.generate(ctx);
}
@Override
@Transactional
public DscLoginStatusResponse verify(final String randomNo, final DscLoginVerifyRequest req) {
final DscChallenge ch = challengeRepository.findByRandomNo(randomNo)
.orElseThrow(() -> new DscChallengeNotFoundException(randomNo));
if (ch.getStatus() != DscChallengeStatus.PENDING) {
throw new DscChallengeInvalidException("Challenge already terminal: " + ch.getStatus());
}
if (ch.isExpired()) {
ch.setStatus(DscChallengeStatus.EXPIRED);
challengeRepository.saveAndFlush(ch);
throw new DscChallengeInvalidException("Challenge expired");
}
if (req == null || req.getCertSerial() == null || req.getSignatureB64() == null) {
failChallenge(ch, "certSerial or signatureB64 missing");
throw new DscChallengeInvalidException("certSerial and signatureB64 are required");
}
// The user we challenged must own the cert serial they signed with. Don't lift the user
// out of the request — read it off the challenge row to prevent serial-substitution attacks.
final AppUser owner = ch.getAppUser();
final BankUserDsc dsc = dscRepository.findByAppUser_IdAndDscSerialNo(owner.getId(), req.getCertSerial()).orElse(null);
if (dsc == null || !dsc.isUsable()) {
failChallenge(ch, "cert serial not enrolled for this user");
return DscLoginStatusResponse.builder().randomNo(randomNo).status(DscChallengeStatus.FAILED.name())
.errorMessage("Selected DSC is not enrolled for this user").build();
}
final boolean ok = signatureVerifier.verify(ch.getChallenge(), req.getSignatureB64(), dsc.getDscPublicKey());
if (!ok) {
failChallenge(ch, "signature verification failed");
return DscLoginStatusResponse.builder().randomNo(randomNo).status(DscChallengeStatus.FAILED.name())
.errorMessage("Signature does not match the enrolled DSC").build();
}
// Mint a TFA access token — same shape MfaFactorBackedTwoFactorServiceImpl creates after a
// successful OTP verify. Default (non-extended) live time.
final int liveSeconds = twoFactorConfigurationService.getAccessTokenLiveTime();
final String token = accessTokenGenerationService.generateRandomToken();
final TFAccessToken accessToken = TFAccessToken.create(token, owner, liveSeconds);
tfAccessTokenRepository.save(accessToken);
ch.setStatus(DscChallengeStatus.VERIFIED);
ch.setVerifiedAt(OffsetDateTime.now(ZoneOffset.UTC));
ch.setCertSerialReturned(req.getCertSerial());
ch.setSignatureB64(req.getSignatureB64());
ch.setDscId(dsc.getId());
// Stash the token on the challenge row — the browser fetches it via /claim. The verify
// response is consumed by the helper, which we don't want carrying a Fineract bearer.
ch.setTfaToken(token);
challengeRepository.saveAndFlush(ch);
LOG.info("DSC login verified — user={}, dscId={}, randomNo={}", owner.getUsername(), dsc.getId(), randomNo);
return DscLoginStatusResponse.builder()
.randomNo(randomNo)
.status(DscChallengeStatus.VERIFIED.name())
.build();
}
@Override
@Transactional
public DscLoginStatusResponse claim(final String randomNo) {
final AppUser caller = securityContext.authenticatedUser();
final DscChallenge ch = challengeRepository.findByRandomNo(randomNo)
.orElseThrow(() -> new DscChallengeNotFoundException(randomNo));
// Owner check — treat non-owner attempts the same as not-found so we don't leak
// randomNos belonging to other users.
if (ch.getAppUser() == null || !ch.getAppUser().getId().equals(caller.getId())) {
throw new DscChallengeNotFoundException(randomNo);
}
if (ch.getStatus() != DscChallengeStatus.VERIFIED) {
throw new DscChallengeInvalidException("Challenge is not in VERIFIED state");
}
if (ch.getTfaToken() == null || ch.getTfaTokenClaimedAt() != null) {
// Already claimed — treat as not-found to keep the endpoint single-shot.
throw new DscChallengeInvalidException("Token already claimed");
}
final String token = ch.getTfaToken();
ch.setTfaToken(null);
ch.setTfaTokenClaimedAt(OffsetDateTime.now(ZoneOffset.UTC));
challengeRepository.saveAndFlush(ch);
LOG.info("DSC login token claimed — user={}, randomNo={}", caller.getUsername(), randomNo);
return DscLoginStatusResponse.builder()
.randomNo(randomNo)
.status(DscChallengeStatus.VERIFIED.name())
.tfaToken(token)
.tfaTokenLiveSeconds((long) twoFactorConfigurationService.getAccessTokenLiveTime())
.build();
}
@Override
@Transactional(readOnly = true)
public DscLoginStatusResponse getStatus(final String randomNo) {
final DscChallenge ch = challengeRepository.findByRandomNo(randomNo)
.orElseThrow(() -> new DscChallengeNotFoundException(randomNo));
// For polling we deliberately do NOT echo the TFA token after the fact — that prevents a
// status URL with the randomNo from being reused to fetch the bearer. The UI gets the token
// exactly once, in the verify response. After that, /status reflects state only.
return DscLoginStatusResponse.builder()
.randomNo(randomNo)
.status(ch.getStatus().name())
.errorMessage(ch.getErrorMessage())
.build();
}
private void failChallenge(final DscChallenge ch, final String reason) {
ch.setStatus(DscChallengeStatus.FAILED);
ch.setErrorMessage(reason);
ch.setAttempts(ch.getAttempts() + 1);
challengeRepository.saveAndFlush(ch);
LOG.info("DSC login verify failed — randomNo={}, reason={}", ch.getRandomNo(), reason);
}
private String newChallengeHex() {
final byte[] buf = new byte[CHALLENGE_BYTES];
secureRandom.nextBytes(buf);
return HexFormat.of().formatHex(buf);
}
}
@@ -0,0 +1,37 @@
/**
* 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.dsc.service;
import java.util.List;
import org.apache.fineract.useradministration.dsc.data.BankUserDscData;
public interface DscReadPlatformService {
/** All non-deleted rows for the currently-authenticated user. */
List<BankUserDscData> listForCurrentUser();
/** Admin view — non-deleted rows for an arbitrary user. */
List<BankUserDscData> listForUser(Long userId);
/** Single-row detail. The caller must already be authorised (self or admin). */
BankUserDscData getById(Long dscId);
/** Soft-revokes a DSC. Both the caller's own DSCs and (with admin perm) any user's. */
void revoke(Long dscId, String reason);
}
@@ -0,0 +1,97 @@
/**
* 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.dsc.service;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.domain.AppUser;
import org.apache.fineract.useradministration.dsc.data.BankUserDscData;
import org.apache.fineract.useradministration.dsc.domain.BankUserDsc;
import org.apache.fineract.useradministration.dsc.domain.BankUserDscRepository;
import org.apache.fineract.useradministration.dsc.domain.CertStatus;
import org.apache.fineract.useradministration.dsc.exception.DscNotFoundException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class DscReadPlatformServiceImpl implements DscReadPlatformService {
private static final Logger LOG = LoggerFactory.getLogger(DscReadPlatformServiceImpl.class);
private final PlatformSecurityContext securityContext;
private final BankUserDscRepository dscRepository;
@Override
@Transactional(readOnly = true)
public List<BankUserDscData> listForCurrentUser() {
final AppUser user = securityContext.authenticatedUser();
return dscRepository.findByAppUser_IdAndDeletedFalseOrderByIdDesc(user.getId()).stream()
.map(d -> BankUserDscData.from(d, true))
.toList();
}
@Override
@Transactional(readOnly = true)
public List<BankUserDscData> listForUser(final Long userId) {
return dscRepository.findByAppUser_IdAndDeletedFalseOrderByIdDesc(userId).stream()
.map(d -> BankUserDscData.from(d, true))
.toList();
}
@Override
@Transactional(readOnly = true)
public BankUserDscData getById(final Long dscId) {
final BankUserDsc d = dscRepository.findById(dscId).orElseThrow(() -> new DscNotFoundException(dscId));
// Non-admins can only see their own DSC. Don't reveal that the id exists for someone else —
// throw not-found instead.
final AppUser caller = securityContext.authenticatedUser();
if (!d.getAppUser().getId().equals(caller.getId())) {
// TODO: relax this once a dedicated 'READ_DSC_ALL' admin permission is wired in.
throw new DscNotFoundException(dscId);
}
return BankUserDscData.from(d, false);
}
@Override
@Transactional
public void revoke(final Long dscId, final String reason) {
final AppUser caller = securityContext.authenticatedUser();
final BankUserDsc d = dscRepository.findById(dscId).orElseThrow(() -> new DscNotFoundException(dscId));
if (!d.getAppUser().getId().equals(caller.getId())) {
// Same not-found masking as getById — don't leak existence to non-owners.
throw new DscNotFoundException(dscId);
}
d.setCertStatus(CertStatus.REVOKED);
d.setActive(false);
d.setDeleted(true);
d.setRevokedOn(OffsetDateTime.now(ZoneOffset.UTC));
d.setRevokedBy(caller.getId());
d.setRevokeReason(reason);
d.setDeletedBy(caller.getId());
d.setDeletedAt(OffsetDateTime.now(ZoneOffset.UTC));
dscRepository.saveAndFlush(d);
LOG.info("DSC revoked — dscId={}, user={}, reason={}", dscId, caller.getUsername(), reason);
}
}
@@ -0,0 +1,63 @@
/**
* 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.dsc.util;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.Signature;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
import org.springframework.stereotype.Component;
/**
* Verifies an RSA-SHA256 signature produced by the helper against the public key stored in
* {@code m_bank_user_dsc.dsc_public_key} (base64 of {@code SubjectPublicKeyInfo}).
*
* <p>The challenge bytes are the UTF-8 bytes of {@code DscChallenge.challenge}. The helper signs
* exactly those bytes — server signs the same — so any mismatch in encoding/casing fails verify.
*/
@Component
public class DscSignatureVerifier {
/**
* @param challenge the original challenge string (server-side authoritative value)
* @param signatureB64 base64-encoded RSA-SHA256 signature returned by the helper
* @param publicKeyB64 base64 SubjectPublicKeyInfo of the user's enrolled cert
* @return true if the signature is valid for the challenge under the given key; false otherwise
* (including any decode/parse failure — we don't distinguish "wrong key" from "bad sig"
* to a caller because both are auth failures)
*/
public boolean verify(final String challenge, final String signatureB64, final String publicKeyB64) {
if (challenge == null || signatureB64 == null || publicKeyB64 == null) {
return false;
}
try {
final byte[] keyBytes = Base64.getDecoder().decode(publicKeyB64);
final PublicKey pubKey = KeyFactory.getInstance("RSA").generatePublic(new X509EncodedKeySpec(keyBytes));
final byte[] sigBytes = Base64.getDecoder().decode(signatureB64);
final Signature sig = Signature.getInstance("SHA256withRSA");
sig.initVerify(pubKey);
sig.update(challenge.getBytes(StandardCharsets.UTF_8));
return sig.verify(sigBytes);
} catch (final Exception e) {
return false;
}
}
}
@@ -0,0 +1,124 @@
/**
* 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.dsc.util;
import java.io.ByteArrayInputStream;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.Base64;
import javax.naming.InvalidNameException;
import javax.naming.ldap.LdapName;
import javax.naming.ldap.Rdn;
import lombok.Builder;
import lombok.Getter;
import org.springframework.stereotype.Component;
/**
* Parses a base64-encoded X.509 DER certificate (sent by the helper) into the metadata fields the
* server persists. Server-side parsing is authoritative — we do not trust whatever the helper
* claims about itself.
*
* <p>Validation done here is minimal: well-formedness + notAfter > now. Chain validation, CRL/OCSP
* checks, and KeyUsage enforcement are intentionally out of scope for v1 and will be layered in
* Phase 4 hardening.
*/
@Component
public class X509CertParser {
public ParsedCert parse(final String certB64) {
if (certB64 == null || certB64.isBlank()) {
throw new IllegalArgumentException("certB64 is required");
}
final byte[] der;
try {
// Tolerate PEM headers/whitespace if the helper sends them — strip non-base64.
final String normalized = certB64.replaceAll("-----[A-Z ]+-----", "").replaceAll("\\s+", "");
der = Base64.getDecoder().decode(normalized);
} catch (final IllegalArgumentException e) {
throw new IllegalArgumentException("certB64 is not valid base64");
}
final X509Certificate cert;
try {
final CertificateFactory cf = CertificateFactory.getInstance("X.509");
cert = (X509Certificate) cf.generateCertificate(new ByteArrayInputStream(der));
} catch (final CertificateException e) {
throw new IllegalArgumentException("certB64 is not a valid X.509 certificate", e);
}
if (cert.getNotAfter().toInstant().isBefore(java.time.Instant.now())) {
throw new IllegalArgumentException("Certificate has already expired (notAfter=" + cert.getNotAfter() + ")");
}
// KeyUsage extension is the standard X.509 way to declare what a cert may be used for.
// bit 0 = digitalSignature, bit 1 = nonRepudiation. We require at least one of these so a
// pure-encryption cert (e.g. dataEncipherment bit only) can't be enrolled as a signing DSC.
// KeyUsage is optional in X.509 — null array means no constraint and we allow it.
final boolean[] keyUsage = cert.getKeyUsage();
if (keyUsage != null && keyUsage.length > 1
&& !keyUsage[0] /* digitalSignature */
&& !keyUsage[1] /* nonRepudiation */) {
throw new IllegalArgumentException("Certificate KeyUsage does not permit signing — pick a Signing DSC, not an Encryption one");
}
final String subjectDn = cert.getSubjectX500Principal().getName();
final String issuerDn = cert.getIssuerX500Principal().getName();
final String commonName = extractCn(subjectDn);
final String serial = cert.getSerialNumber().toString(16).toUpperCase();
final LocalDate notAfter = cert.getNotAfter().toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
final String publicKeyB64 = Base64.getEncoder().encodeToString(cert.getPublicKey().getEncoded());
return ParsedCert.builder()
.serial(serial)
.subjectDn(subjectDn)
.issuerDn(issuerDn)
.commonName(commonName)
.notAfter(notAfter)
.publicKeyB64(publicKeyB64)
.build();
}
private static String extractCn(final String dn) {
try {
final LdapName name = new LdapName(dn);
for (final Rdn rdn : name.getRdns()) {
if ("CN".equalsIgnoreCase(rdn.getType())) {
return String.valueOf(rdn.getValue());
}
}
} catch (final InvalidNameException ignored) {
// Fall through — return null if DN is malformed.
}
return null;
}
@Getter
@Builder
public static class ParsedCert {
private final String serial;
private final String subjectDn;
private final String issuerDn;
private final String commonName;
private final LocalDate notAfter;
private final String publicKeyB64;
}
}
@@ -94,6 +94,15 @@ public class AppUserWritePlatformServiceJpaRepositoryImpl implements AppUserWrit
this.fromApiJsonDeserializer.validateForCreate(command.json());
// Pre-flight unique-checks for email + mobile so the UI gets a clean field-scoped
// error instead of the generic data-integrity error that fires after the DB UNIQUE
// constraint trips. The DB constraint is still the source of truth for race-condition
// safety — see handleDataIntegrityIssues() for that backstop.
assertEmailAndMobileUnique(
command.stringValueOfParameterNamed("email"),
command.stringValueOfParameterNamed("mobileNo"),
null);
final String officeIdParamName = "officeId";
final Long officeId = command.longValueOfParameterNamed(officeIdParamName);
@@ -199,6 +208,13 @@ public class AppUserWritePlatformServiceJpaRepositoryImpl implements AppUserWrit
this.fromApiJsonDeserializer.validateForUpdate(command.json(), currentUser);
// Same pre-flight as createUser, excluding the user being edited so they're not told
// their own email/mobile is taken by themselves.
assertEmailAndMobileUnique(
command.stringValueOfParameterNamed("email"),
command.stringValueOfParameterNamed("mobileNo"),
userId);
final AppUser userToUpdate = this.appUserRepository.findById(userId).orElseThrow(() -> new UserNotFoundException(userId));
final AppUserPreviousPassword currentPasswordToSaveAsPreview = getCurrentPasswordToSaveAsPreview(userToUpdate, command);
@@ -342,6 +358,19 @@ public class AppUserWritePlatformServiceJpaRepositoryImpl implements AppUserWrit
final String defaultMessage = "User with username " + username + " already exists.";
return new PlatformDataIntegrityException("error.msg.user.duplicate.username", defaultMessage, "username", username);
}
// Email / mobile race-condition backstop. The pre-flight check in createUser/updateUser
// catches almost all cases cleanly, but two parallel inserts can still race past it and
// collide at the DB unique constraint — translate that into the same field-scoped error.
if (realCause.getMessage().contains("uq_m_appuser_email")) {
final String email = command.stringValueOfParameterNamed("email");
return new PlatformDataIntegrityException("error.msg.user.duplicate.email",
"User with email " + email + " already exists.", "email", email);
}
if (realCause.getMessage().contains("uq_m_appuser_mobile_no")) {
final String mobile = command.stringValueOfParameterNamed("mobileNo");
return new PlatformDataIntegrityException("error.msg.user.duplicate.mobile",
"User with mobile number " + mobile + " already exists.", "mobileNo", mobile);
}
// TODO: this needs to be fixed. The error condition should be independent from the underlying message and
// naming of the constraint
if (realCause.getMessage().contains("unique_self_client")) {
@@ -352,4 +381,37 @@ public class AppUserWritePlatformServiceJpaRepositoryImpl implements AppUserWrit
log.error("handleDataIntegrityIssues: Neither duplicate username nor existing user; unknown error occured", dve);
return ErrorHandler.getMappable(dve, "error.msg.unknown.data.integrity.issue", "Unknown data integrity issue with resource.");
}
/**
* Pre-flight uniqueness check for {@code email} and {@code mobileNo}. Runs before the DB
* write so the UI gets a clean field-scoped error instead of the generic
* "data integrity issue" the constraint trap would produce.
*
* <p>On create, pass {@code excludeUserId = null}. On update, pass the id of the user being
* edited so they aren't told their own values collide with themselves.
*
* <p>Blank / null email is left to the existing {@link UserDataValidator#notBlank()} check
* (email is NOT NULL in {@code m_appuser}). Blank / null mobile is skipped — it's optional
* and multiple users with no mobile are valid.
*/
private void assertEmailAndMobileUnique(final String email, final String mobileNo, final Long excludeUserId) {
if (email != null && !email.isBlank()) {
final java.util.Optional<AppUser> emailOwner = excludeUserId == null
? this.appUserRepository.findByEmailIgnoreCase(email)
: this.appUserRepository.findByEmailIgnoreCaseAndIdNot(email, excludeUserId);
if (emailOwner.isPresent()) {
throw new PlatformDataIntegrityException("error.msg.user.duplicate.email",
"User with email " + email + " already exists.", "email", email);
}
}
if (mobileNo != null && !mobileNo.isBlank()) {
final java.util.Optional<AppUser> mobileOwner = excludeUserId == null
? this.appUserRepository.findByMobileNo(mobileNo)
: this.appUserRepository.findByMobileNoAndIdNot(mobileNo, excludeUserId);
if (mobileOwner.isPresent()) {
throw new PlatformDataIntegrityException("error.msg.user.duplicate.mobile",
"User with mobile number " + mobileNo + " already exists.", "mobileNo", mobileNo);
}
}
}
}
@@ -664,8 +664,6 @@ fineract.openproject.api-token=${FINERACT_OPENPROJECT_API_TOKEN:57dd9f933f113c72
# ── DIP / MNRL Mobile Number Lookup ──────────────────────────────────────────
fineract.mobile-lookup.base-url=${FINERACT_MOBILE_LOOKUP_BASE_URL:http://localhost:8080}
# ── Login OTP (2FA) — uses LincPay credentials above ─────────────────────────
fineract.security.otp.lincpay.generate-path=${FINERACT_OTP_LINCPAY_GENERATE_PATH:/mobile/generate-otp}
fineract.security.otp.lincpay.verify-path=${FINERACT_OTP_LINCPAY_VERIFY_PATH:/verify-mobile-otp}
@@ -686,3 +684,4 @@ spring.mail.properties.mail.transport.protocol=smtp
fineract.email.from-address=${FINERACT_EMAIL_FROM_ADDRESS:no-reply@cbsnexora.com}
fineract.email.from-name=${FINERACT_EMAIL_FROM_NAME:CBSnexora}
fineract.email.support-address=${FINERACT_EMAIL_SUPPORT_ADDRESS:support@cbsnexora.com}
@@ -308,4 +308,8 @@
<include file="parts/0289_seed_read_otp_log_permission.xml" relativeToChangelogFile="true"/>
<include file="parts/0290_create_otp_log_auditor_role.xml" relativeToChangelogFile="true"/>
<include file="parts/0291_grant_read_otp_log_to_bankadmin_roles.xml" relativeToChangelogFile="true"/>
<include file="parts/0292_create_m_bank_user_dsc.xml" relativeToChangelogFile="true"/>
<include file="parts/0293_create_m_dsc_challenge.xml" relativeToChangelogFile="true"/>
<include file="parts/0294_add_tfa_token_to_dsc_challenge.xml" relativeToChangelogFile="true"/>
<include file="parts/0295_unique_email_mobile_on_m_appuser.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,168 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
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
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!--
Stores Digital Signature Certificate (DSC) mappings per AppUser. A user may have multiple
rows; queries filter on (is_active = true, is_deleted = false, cert_status = 'ACTIVE') to
find usable mappings.
cert serial is globally unique — one cert in the world has one serial — so it's the natural
lookup key at login-verify time.
-->
<changeSet id="0292_create_m_bank_user_dsc" author="fineract">
<preConditions onFail="MARK_RAN"><not><tableExists tableName="m_bank_user_dsc"/></not></preConditions>
<createTable tableName="m_bank_user_dsc">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints nullable="false" primaryKey="true"/>
</column>
<column name="appuser_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<!-- Parity with external 'bank_user_dsc' reference schema -->
<column name="dsc_user_id" type="VARCHAR(64)"/>
<column name="dsc_serial_no" type="VARCHAR(128)">
<constraints nullable="false" unique="true" uniqueConstraintName="uq_bank_user_dsc_serial"/>
</column>
<column name="dsc_name" type="VARCHAR(255)"/>
<column name="dsc_public_key" type="TEXT">
<constraints nullable="false"/>
</column>
<column name="dsc_expiry_date" type="DATE">
<constraints nullable="false"/>
</column>
<column name="date_of_submission" type="datetime" defaultValueComputed="CURRENT_TIMESTAMP">
<constraints nullable="false"/>
</column>
<column name="dsc_type" type="VARCHAR(32)">
<constraints nullable="false"/>
</column>
<column name="common_name" type="VARCHAR(255)"/>
<!-- Cert metadata for display + future chain validation -->
<column name="issued_to" type="VARCHAR(512)"/>
<column name="issued_by" type="VARCHAR(512)"/>
<!-- Lifecycle: ACTIVE / REVOKED / EXPIRED -->
<column name="cert_status" type="VARCHAR(16)" defaultValue="ACTIVE">
<constraints nullable="false"/>
</column>
<column name="revoked_on" type="datetime"/>
<column name="revoked_by" type="BIGINT"/>
<column name="revoke_reason" type="VARCHAR(255)"/>
<!-- Standard Fineract soft-delete + audit columns -->
<column name="is_active" type="boolean" defaultValueBoolean="true">
<constraints nullable="false"/>
</column>
<column name="is_deleted" type="boolean" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
<column name="created_by" type="BIGINT"/>
<column name="created_at" type="datetime" defaultValueComputed="CURRENT_TIMESTAMP">
<constraints nullable="false"/>
</column>
<column name="modified_by" type="BIGINT"/>
<column name="modified_on" type="datetime"/>
<column name="deleted_by" type="BIGINT"/>
<column name="deleted_at" type="datetime"/>
<column name="version" type="INT" defaultValueNumeric="0">
<constraints nullable="false"/>
</column>
</createTable>
<addForeignKeyConstraint baseTableName="m_bank_user_dsc" baseColumnNames="appuser_id"
constraintName="fk_bank_user_dsc_user"
referencedTableName="m_appuser" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_bank_user_dsc" baseColumnNames="revoked_by"
constraintName="fk_bank_user_dsc_revoked_by"
referencedTableName="m_appuser" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_bank_user_dsc" baseColumnNames="created_by"
constraintName="fk_bank_user_dsc_created_by"
referencedTableName="m_appuser" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_bank_user_dsc" baseColumnNames="modified_by"
constraintName="fk_bank_user_dsc_modified_by"
referencedTableName="m_appuser" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_bank_user_dsc" baseColumnNames="deleted_by"
constraintName="fk_bank_user_dsc_deleted_by"
referencedTableName="m_appuser" referencedColumnNames="id"/>
<!-- Common query: list active DSCs for a user -->
<createIndex tableName="m_bank_user_dsc" indexName="idx_bank_user_dsc_user_status">
<column name="appuser_id"/>
<column name="cert_status"/>
<column name="is_deleted"/>
</createIndex>
<!-- Scheduled job: flip ACTIVE → EXPIRED when notAfter passes -->
<createIndex tableName="m_bank_user_dsc" indexName="idx_bank_user_dsc_expiry">
<column name="dsc_expiry_date"/>
<column name="cert_status"/>
</createIndex>
</changeSet>
<!-- Permissions: read / create / delete (delete = soft revoke) -->
<changeSet id="0292_seed_dsc_permissions" author="fineract">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'READ_DSC'</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="special"/>
<column name="code" value="READ_DSC"/>
<column name="entity_name" value="DSC"/>
<column name="action_name" value="READ"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="special"/>
<column name="code" value="CREATE_DSC"/>
<column name="entity_name" value="DSC"/>
<column name="action_name" value="CREATE"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="special"/>
<column name="code" value="DELETE_DSC"/>
<column name="entity_name" value="DSC"/>
<column name="action_name" value="DELETE"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
</changeSet>
<!-- Self Service User gets the trio so end-users can enroll/revoke their own DSC. -->
<changeSet id="0292_grant_dsc_permissions_to_self_service_role" author="fineract">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="1">SELECT COUNT(*) FROM m_role WHERE name = 'Self Service User'</sqlCheck>
</preConditions>
<sql>
INSERT INTO m_role_permission (role_id, permission_id)
SELECT r.id, p.id
FROM m_role r, m_permission p
WHERE r.name = 'Self Service User'
AND p.code IN ('READ_DSC', 'CREATE_DSC', 'DELETE_DSC')
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp
WHERE rp.role_id = r.id AND rp.permission_id = p.id
)
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,83 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
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
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!--
One-shot nonce for a DSC enroll or login-2FA cycle. Rows are short-lived (~2 min TTL) and
transition PENDING → VERIFIED / FAILED / EXPIRED exactly once. Kept after terminal state for
audit; cleanup is the job of a scheduled task.
random_no is the public handle embedded in the JNLP and in the helper's callback URL —
UUID v4 length, sufficient entropy that guessing a pending nonce is infeasible.
-->
<changeSet id="0293_create_m_dsc_challenge" author="fineract">
<preConditions onFail="MARK_RAN"><not><tableExists tableName="m_dsc_challenge"/></not></preConditions>
<createTable tableName="m_dsc_challenge">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints nullable="false" primaryKey="true"/>
</column>
<column name="random_no" type="VARCHAR(64)">
<constraints nullable="false" unique="true" uniqueConstraintName="uq_dsc_challenge_random_no"/>
</column>
<column name="appuser_id" type="BIGINT"/>
<column name="purpose" type="VARCHAR(32)">
<constraints nullable="false"/>
</column>
<column name="challenge" type="VARCHAR(128)">
<constraints nullable="false"/>
</column>
<column name="status" type="VARCHAR(16)" defaultValue="PENDING">
<constraints nullable="false"/>
</column>
<column name="cert_serial_returned" type="VARCHAR(128)"/>
<column name="signature_b64" type="TEXT"/>
<column name="dsc_id" type="BIGINT"/>
<column name="error_message" type="VARCHAR(512)"/>
<column name="attempts" type="INT" defaultValueNumeric="0">
<constraints nullable="false"/>
</column>
<column name="expires_at" type="datetime">
<constraints nullable="false"/>
</column>
<column name="verified_at" type="datetime"/>
<column name="created_at" type="datetime" defaultValueComputed="CURRENT_TIMESTAMP">
<constraints nullable="false"/>
</column>
<column name="ip_address" type="VARCHAR(64)"/>
<column name="user_agent" type="VARCHAR(512)"/>
</createTable>
<addForeignKeyConstraint baseTableName="m_dsc_challenge" baseColumnNames="appuser_id"
constraintName="fk_dsc_challenge_user"
referencedTableName="m_appuser" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_dsc_challenge" baseColumnNames="dsc_id"
constraintName="fk_dsc_challenge_dsc"
referencedTableName="m_bank_user_dsc" referencedColumnNames="id"/>
<!-- "Open challenges for this user" — drives the status-poll endpoint -->
<createIndex tableName="m_dsc_challenge" indexName="idx_dsc_challenge_user_status">
<column name="appuser_id"/>
<column name="status"/>
</createIndex>
<!-- Cleanup job filter -->
<createIndex tableName="m_dsc_challenge" indexName="idx_dsc_challenge_expires_at">
<column name="expires_at"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
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
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!--
DSC login flow stores the minted TFA access token on the challenge row instead of returning
it to the helper. The browser then calls POST /v1/login/dsc/{rn}/claim (basic-auth required)
to retrieve the token exactly once.
This stops the helper from carrying a bearer credential back into a browser process and
keeps the token's emission scoped to a fresh authenticated request.
-->
<changeSet id="0294_add_tfa_token_to_dsc_challenge" author="fineract">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_dsc_challenge"/>
<not><columnExists tableName="m_dsc_challenge" columnName="tfa_token"/></not>
</and>
</preConditions>
<addColumn tableName="m_dsc_challenge">
<column name="tfa_token" type="VARCHAR(255)"/>
<column name="tfa_token_claimed_at" type="datetime"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
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
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!--
Enforce uniqueness on m_appuser.email and m_appuser.mobile_no.
Username is already unique (constraint name 'username_org' from the upstream schema).
Email is NOT NULL in the entity; mobile_no IS nullable. Both PostgreSQL and MySQL allow
multiple NULLs in a UNIQUE constraint, so a plain UNIQUE works for mobile_no — multiple
users with no mobile remain valid.
Note: if your existing data already contains duplicate emails or mobiles, this migration
WILL fail. Dedupe first:
SELECT LOWER(email), COUNT(*) FROM m_appuser GROUP BY LOWER(email) HAVING COUNT(*) > 1;
SELECT mobile_no, COUNT(*) FROM m_appuser WHERE mobile_no IS NOT NULL GROUP BY mobile_no HAVING COUNT(*) > 1;
-->
<changeSet id="0295_add_unique_email_to_m_appuser" author="fineract">
<preConditions onFail="MARK_RAN">
<not>
<indexExists tableName="m_appuser" indexName="uq_m_appuser_email"/>
</not>
</preConditions>
<addUniqueConstraint tableName="m_appuser" columnNames="email"
constraintName="uq_m_appuser_email"/>
</changeSet>
<changeSet id="0295_add_unique_mobile_no_to_m_appuser" author="fineract">
<preConditions onFail="MARK_RAN">
<not>
<indexExists tableName="m_appuser" indexName="uq_m_appuser_mobile_no"/>
</not>
</preConditions>
<!-- Drop the plain non-unique index added in 0283 first — the unique constraint replaces it. -->
<dropIndex tableName="m_appuser" indexName="idx_m_appuser_mobile_no"/>
<addUniqueConstraint tableName="m_appuser" columnNames="mobile_no"
constraintName="uq_m_appuser_mobile_no"/>
</changeSet>
</databaseChangeLog>