merege code feature/Backend-DSC-MFA branch

This commit is contained in:
Nitesh Yadav
2026-05-12 12:48:43 +05:30
54 changed files with 5428 additions and 9 deletions
@@ -0,0 +1,162 @@
/**
* 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.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
import org.apache.fineract.useradministration.domain.AppUser;
/**
* One row per DSC mapping. A user may have multiple rows — we filter by {@link CertStatus} to find
* active mappings. The cert serial is globally unique (one cert in the world has one serial), so it
* doubles as a natural key.
*
* <p>Mirrors the shape of an externally-reference {@code bank_user_dsc} table from the procurement
* project, with Fineract conventions layered on top: FK to {@code m_appuser}, snake_case columns,
* proper DATE/TIMESTAMP types, soft-delete + audit columns, and optimistic locking via
* {@code version}.
*/
@Entity
@Table(name = "m_bank_user_dsc")
@Getter
@Setter
@NoArgsConstructor
public class BankUserDsc extends AbstractPersistableCustom<Long> {
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "appuser_id", nullable = false)
private AppUser appUser;
/**
* Free-text user identifier retained for parity with the source schema (often the username or
* an external system ID). The {@link #appUser} FK is authoritative; this is for display /
* cross-system reconciliation only.
*/
@Column(name = "dsc_user_id", length = 64)
private String dscUserId;
@Column(name = "dsc_serial_no", length = 128, nullable = false, unique = true)
private String dscSerialNo;
@Column(name = "dsc_name", length = 255)
private String dscName;
/** Base64-encoded SubjectPublicKeyInfo. Used at login-factor time to verify the signature. */
@Column(name = "dsc_public_key", columnDefinition = "TEXT", nullable = false)
private String dscPublicKey;
@Column(name = "dsc_expiry_date", nullable = false)
private LocalDate dscExpiryDate;
@Column(name = "date_of_submission", nullable = false)
private OffsetDateTime dateOfSubmission;
@Enumerated(EnumType.STRING)
@Column(name = "dsc_type", length = 32, nullable = false)
private DscType dscType;
@Column(name = "common_name", length = 255)
private String commonName;
/** Cert subject DN. */
@Column(name = "issued_to", length = 512)
private String issuedTo;
/** Cert issuer DN — useful in the UI ("eMudhra Sub CA for Class 3 ..."). */
@Column(name = "issued_by", length = 512)
private String issuedBy;
@Enumerated(EnumType.STRING)
@Column(name = "cert_status", length = 16, nullable = false)
private CertStatus certStatus;
@Column(name = "revoked_on")
private OffsetDateTime revokedOn;
@Column(name = "revoked_by")
private Long revokedBy;
@Column(name = "revoke_reason", length = 255)
private String revokeReason;
@Column(name = "is_active", nullable = false)
private boolean active;
@Column(name = "is_deleted", nullable = false)
private boolean deleted;
@Column(name = "created_by")
private Long createdBy;
@Column(name = "created_at", nullable = false, updatable = false)
private OffsetDateTime createdAt;
@Column(name = "modified_by")
private Long modifiedBy;
@Column(name = "modified_on")
private OffsetDateTime modifiedOn;
@Column(name = "deleted_by")
private Long deletedBy;
@Column(name = "deleted_at")
private OffsetDateTime deletedAt;
@Version
@Column(name = "version", nullable = false)
private Integer version;
@PrePersist
private void prePersist() {
final OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC);
if (createdAt == null) {
createdAt = now;
}
if (dateOfSubmission == null) {
dateOfSubmission = now;
}
if (certStatus == null) {
certStatus = CertStatus.ACTIVE;
}
}
/**
* Convenience flip: returns true if this row is the kind we'd present in the UI / accept as an
* MFA factor. Cheaper to call than re-checking the three flags everywhere.
*/
public boolean isUsable() {
return active && !deleted && certStatus == CertStatus.ACTIVE;
}
}
@@ -0,0 +1,50 @@
/**
* 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.domain;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface BankUserDscRepository extends JpaRepository<BankUserDsc, Long> {
/** All non-deleted rows for the user, newest first — admin view + self list. */
List<BankUserDsc> findByAppUser_IdAndDeletedFalseOrderByIdDesc(Long userId);
/** Only rows usable as an MFA factor (active + not deleted + cert_status=ACTIVE). */
List<BankUserDsc> findByAppUser_IdAndActiveTrueAndDeletedFalseAndCertStatus(Long userId, CertStatus certStatus);
/** Lookup by globally-unique cert serial — used at login-verify time. */
Optional<BankUserDsc> findByDscSerialNo(String dscSerialNo);
/** Used at login-verify time to confirm "this user enrolled this serial". */
Optional<BankUserDsc> findByAppUser_IdAndDscSerialNo(Long userId, String dscSerialNo);
/**
* Login gate: returns true if the user has at least one usable DSC mapping that's also not
* past its notAfter date. The {@code dscExpiryDate &gt;= today} clause prevents 2FA-gating a
* user who's enrolled but whose cert silently expired between log-ins — they'd otherwise be
* stuck in a verification loop with a cert the helper would sign with but the server would
* accept (the verify side filters too, but the gate is what kicks the user into the DSC flow
* in the first place).
*/
boolean existsByAppUser_IdAndActiveTrueAndDeletedFalseAndCertStatusAndDscExpiryDateGreaterThanEqual(
Long userId, CertStatus certStatus, LocalDate today);
}
@@ -0,0 +1,46 @@
/**
* 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.domain;
/**
* Lifecycle of a DSC mapping row in m_bank_user_dsc.
*
* <ul>
* <li>{@link #ACTIVE} — enrolled and usable as an MFA factor.</li>
* <li>{@link #REVOKED} — voluntarily removed by user or admin; kept for audit.</li>
* <li>{@link #EXPIRED} — cert past notAfter date; flipped by a scheduled job, not on read.</li>
* </ul>
*/
public enum CertStatus {
ACTIVE,
REVOKED,
EXPIRED;
public static CertStatus fromString(final String s) {
if (s == null) {
return null;
}
for (final CertStatus t : values()) {
if (t.name().equalsIgnoreCase(s)) {
return t;
}
}
return null;
}
}
@@ -0,0 +1,133 @@
/**
* 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.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
import org.apache.fineract.useradministration.domain.AppUser;
/**
* One-shot challenge nonce for a DSC enroll or login-2FA cycle.
*
* <p>The {@link #randomNo} is the public handle the JNLP file and the helper callback use; it must
* be a sufficiently long random value (UUID v4) to make guessing infeasible. The {@link #challenge}
* is the raw bytes the helper actually signs in LOGIN_2FA flow.
*
* <p>The row is single-use: once {@code status} flips to VERIFIED or FAILED, further callbacks
* referencing the same randomNo must be rejected. Cleanup of EXPIRED/FAILED rows is left to a
* scheduled job (TBD) — we keep them for audit.
*/
@Entity
@Table(name = "m_dsc_challenge")
@Getter
@Setter
@NoArgsConstructor
public class DscChallenge extends AbstractPersistableCustom<Long> {
@Column(name = "random_no", length = 64, nullable = false, unique = true)
private String randomNo;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "appuser_id")
private AppUser appUser;
@Enumerated(EnumType.STRING)
@Column(name = "purpose", length = 32, nullable = false)
private DscChallengePurpose purpose;
/** Random 32-byte payload (hex-encoded) the helper signs with the user's DSC private key. */
@Column(name = "challenge", length = 128, nullable = false)
private String challenge;
@Enumerated(EnumType.STRING)
@Column(name = "status", length = 16, nullable = false)
private DscChallengeStatus status;
/** Serial returned by the helper — set in callback; the server verifies it matches the user's enrolled cert. */
@Column(name = "cert_serial_returned", length = 128)
private String certSerialReturned;
/** Base64-encoded RSA-SHA256 signature returned by the helper. NULL for ENROLL purpose. */
@Column(name = "signature_b64", columnDefinition = "TEXT")
private String signatureB64;
/** FK populated on successful LOGIN_2FA verify — tells us *which* DSC row was used. */
@Column(name = "dsc_id")
private Long dscId;
@Column(name = "error_message", length = 512)
private String errorMessage;
@Column(name = "attempts", nullable = false)
private int attempts;
@Column(name = "expires_at", nullable = false)
private OffsetDateTime expiresAt;
@Column(name = "verified_at")
private OffsetDateTime verifiedAt;
/**
* TFA access token minted during a successful LOGIN_2FA verify. Held on the row until the
* browser claims it via {@code POST /v1/login/dsc/{rn}/claim}; that call clears the field by
* stamping {@link #tfaTokenClaimedAt}. Single-use.
*/
@Column(name = "tfa_token", length = 255)
private String tfaToken;
@Column(name = "tfa_token_claimed_at")
private OffsetDateTime tfaTokenClaimedAt;
@Column(name = "created_at", nullable = false, updatable = false)
private OffsetDateTime createdAt;
@Column(name = "ip_address", length = 64)
private String ipAddress;
@Column(name = "user_agent", length = 512)
private String userAgent;
@PrePersist
private void prePersist() {
if (createdAt == null) {
createdAt = OffsetDateTime.now(ZoneOffset.UTC);
}
if (status == null) {
status = DscChallengeStatus.PENDING;
}
}
/** True if the row is past its TTL — used by the verify endpoint before any signature math. */
public boolean isExpired() {
return expiresAt != null && OffsetDateTime.now(ZoneOffset.UTC).isAfter(expiresAt);
}
}
@@ -0,0 +1,48 @@
/**
* 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.domain;
/**
* Why a particular {@code m_dsc_challenge} row was created.
*
* <ul>
* <li>{@link #ENROLL} — helper returns cert metadata only; server upserts m_bank_user_dsc.</li>
* <li>{@link #LOGIN_2FA} — helper signs the challenge nonce; server verifies the signature
* against the stored public key and grants TWOFACTOR_AUTHENTICATED on success.</li>
* <li>{@link #APPROVAL} — same as LOGIN_2FA but bound to a specific business action.
* Future use; persistence layer is ready.</li>
* </ul>
*/
public enum DscChallengePurpose {
ENROLL,
LOGIN_2FA,
APPROVAL;
public static DscChallengePurpose fromString(final String s) {
if (s == null) {
return null;
}
for (final DscChallengePurpose t : values()) {
if (t.name().equalsIgnoreCase(s)) {
return t;
}
}
return null;
}
}
@@ -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.domain;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface DscChallengeRepository extends JpaRepository<DscChallenge, Long> {
/** Single lookup used by every callback / status / verify endpoint. */
Optional<DscChallenge> findByRandomNo(String randomNo);
}
@@ -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.domain;
public enum DscChallengeStatus {
PENDING,
VERIFIED,
FAILED,
EXPIRED;
public static DscChallengeStatus fromString(final String s) {
if (s == null) {
return null;
}
for (final DscChallengeStatus t : values()) {
if (t.name().equalsIgnoreCase(s)) {
return t;
}
}
return null;
}
}
@@ -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.domain;
public enum DscType {
SIGNING,
ENCRYPTION;
public static DscType fromString(final String s) {
if (s == null) {
return null;
}
for (final DscType t : values()) {
if (t.name().equalsIgnoreCase(s)) {
return t;
}
}
return null;
}
}