merege code feature/Backend-DSC-MFA branch
This commit is contained in:
+28
-1
@@ -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() {
|
||||
|
||||
+6
-1
@@ -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() {
|
||||
|
||||
+8
-1
@@ -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) {
|
||||
|
||||
+12
@@ -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);
|
||||
|
||||
|
||||
+49
@@ -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,
|
||||
|
||||
+15
@@ -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()) //
|
||||
|
||||
+23
@@ -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);
|
||||
}
|
||||
|
||||
+213
@@ -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();
|
||||
}
|
||||
}
|
||||
+140
@@ -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();
|
||||
}
|
||||
}
|
||||
+98
@@ -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);
|
||||
}
|
||||
}
|
||||
+36
@@ -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;
|
||||
}
|
||||
+45
@@ -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;
|
||||
}
|
||||
+52
@@ -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;
|
||||
}
|
||||
+38
@@ -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;
|
||||
}
|
||||
+38
@@ -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;
|
||||
}
|
||||
+39
@@ -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;
|
||||
}
|
||||
+32
@@ -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);
|
||||
}
|
||||
}
|
||||
+28
@@ -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);
|
||||
}
|
||||
}
|
||||
+28
@@ -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);
|
||||
}
|
||||
}
|
||||
+34
@@ -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);
|
||||
}
|
||||
}
|
||||
+34
@@ -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);
|
||||
}
|
||||
+255
@@ -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;
|
||||
}
|
||||
}
|
||||
+125
@@ -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("&", "&").replace("<", "<").replace(">", ">").replace("\"", """).replace("'", "'");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
+41
@@ -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);
|
||||
}
|
||||
+282
@@ -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);
|
||||
}
|
||||
}
|
||||
+37
@@ -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);
|
||||
}
|
||||
+97
@@ -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);
|
||||
}
|
||||
}
|
||||
+63
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+124
@@ -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;
|
||||
}
|
||||
}
|
||||
+62
@@ -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>
|
||||
|
||||
+168
@@ -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>
|
||||
+83
@@ -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>
|
||||
+39
@@ -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>
|
||||
+54
@@ -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>
|
||||
Reference in New Issue
Block a user