Compare commits
2
Commits
e4cc2e558e
...
317a308d8c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
317a308d8c | ||
|
|
a83806062e |
+9
@@ -163,6 +163,15 @@ public class SecurityConfig {
|
||||
// packages here without a Fineract Bearer token; security is enforced by the
|
||||
// envelope's RSA signature/encryption (NpciCryptoUtil) inside the handler.
|
||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/npci/**")).permitAll()
|
||||
// Client account summary, third-party callers — a partner integration calls this
|
||||
// with an X-Api-Key header instead of a Fineract username/password (issued via
|
||||
// POST /v1/apiclients, super-user only). Same posture as IMPS/NPCI above: the
|
||||
// outer gate stays open for this one narrow GET path, and
|
||||
// ClientAllAccountsApiResource#retrieveByClientNo is what actually authenticates
|
||||
// the caller (ApiClientCredentialService checks the key's hash, active flag and
|
||||
// IP allowlist) — a request with no key and no Fineract login still gets nothing,
|
||||
// it just fails inside the handler instead of at this filter.
|
||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/clientaccounts/**")).permitAll()
|
||||
// Login OTP — same posture as PAN/GSTIN public verification: provider-credentialed,
|
||||
// no Fineract auth required.
|
||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/login/otp/generate")).permitAll()
|
||||
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* 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.infrastructure.security.apiclient;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonElement;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.DELETE;
|
||||
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.WebApplicationException;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Admin-only management of the third-party API keys {@link ApiClientCredentialService} authenticates.
|
||||
* This resource itself requires a normal Fineract login, same as everything else -- ordinary
|
||||
* username/password, restricted to a super user (ALL_FUNCTIONS). Only the endpoints that use a minted
|
||||
* key to skip login (e.g. GET /v1/clientaccounts) are the ones exempted in SecurityConfig; issuing that
|
||||
* key in the first place is not something to hand to just anyone.
|
||||
*/
|
||||
@Path("/v1/apiclients")
|
||||
@Component
|
||||
@Tag(name = "API Client Keys", description = "Admin management of third-party API keys used by endpoints that skip Fineract login.")
|
||||
@RequiredArgsConstructor
|
||||
public class ApiClientAdminApiResource {
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final ApiClientCredentialService credentialService;
|
||||
private final FromJsonHelper fromJsonHelper;
|
||||
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
||||
|
||||
@POST
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String create(final String body) {
|
||||
final AppUser user = requireSuperUser();
|
||||
final JsonElement root = fromJsonHelper.parse(body);
|
||||
final String clientName = fromJsonHelper.extractStringNamed("clientName", root);
|
||||
final String allowedIps = fromJsonHelper.extractStringNamed("allowedIps", root);
|
||||
|
||||
final Map<String, Object> result = credentialService.createClient(clientName, allowedIps, user.getId());
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String list() {
|
||||
requireSuperUser();
|
||||
final List<Map<String, Object>> clients = credentialService.listClients();
|
||||
return gson.toJson(clients);
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@Path("/{id}")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String deactivate(@PathParam("id") final Long id) {
|
||||
requireSuperUser();
|
||||
credentialService.setActive(id, false);
|
||||
return gson.toJson(Map.of("resourceId", id, "isActive", false));
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/{id}/reactivate")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String reactivate(@PathParam("id") final Long id) {
|
||||
requireSuperUser();
|
||||
credentialService.setActive(id, true);
|
||||
return gson.toJson(Map.of("resourceId", id, "isActive", true));
|
||||
}
|
||||
|
||||
private AppUser requireSuperUser() {
|
||||
final AppUser user = context.authenticatedUser();
|
||||
if (!user.hasAnyPermission("ALL_FUNCTIONS")) {
|
||||
throw new WebApplicationException(Response.status(403)
|
||||
.entity("{\"message\":\"Only a super user can manage third-party API keys.\"}").type(MediaType.APPLICATION_JSON)
|
||||
.build());
|
||||
}
|
||||
return user;
|
||||
}
|
||||
}
|
||||
+243
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* 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.infrastructure.security.apiclient;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.ws.rs.WebApplicationException;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import javax.sql.DataSource;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Authenticates third-party callers of endpoints that are meant to be reachable WITHOUT a Fineract user
|
||||
* login -- an {@code X-Api-Key} header instead of a username/password. Same posture already used in this
|
||||
* fork for the IMPS and NPCI receivers ({@code SecurityConfig}: those paths are {@code permitAll()} at
|
||||
* the Spring Security layer, and the handler itself verifies the caller's credential): the outer gate
|
||||
* stays open for the specific path, and this class is the thing that actually decides "yes" or "no".
|
||||
*
|
||||
* {@code m_api_client} lives in the tenant-STORE database ({@code fineract_tenants}), reached here via
|
||||
* the {@code hikariTenantDataSource}-qualified {@link DataSource} -- the same bean
|
||||
* {@code TenantDatabaseUpgradeService} uses to manage that database -- rather than the ordinary
|
||||
* tenant-routed {@code JdbcTemplate} every other resource in this fork injects. That is deliberate, not
|
||||
* an oversight: a key is a caller's identity, independent of which bank's data a given request is asking
|
||||
* for, so one minted key must keep working against every tenant that exists now AND every one
|
||||
* provisioned later. A copy of this table inside each bank's own tenant database would need a manual
|
||||
* re-insert on every new bank, and every key would still need one row per tenant -- exactly what storing
|
||||
* it centrally, once, avoids. {@code Fineract-Platform-TenantId} keeps doing what it always did (routes
|
||||
* the REST of the request, e.g. {@code ClientAllAccountsApiResource}'s own client/account lookups, to
|
||||
* the right bank's database) -- this class only ever touches the one central table.
|
||||
*
|
||||
* Security properties, deliberately:
|
||||
* <ul>
|
||||
* <li>Only a SHA-256 hash of the key is ever stored ({@code m_api_client.api_key_hash}). A leaked
|
||||
* database backup can never be turned back into a usable credential -- the same reason Fineract itself
|
||||
* never stores a user's plaintext password.</li>
|
||||
* <li>The raw key is shown to the caller (well, the admin minting it) exactly once, at creation. It is
|
||||
* not retrievable afterwards, by design -- if it's lost, the only recourse is to mint a new one and
|
||||
* deactivate the old.</li>
|
||||
* <li>{@code authenticate()} raises the SAME 401 message ("Invalid or inactive API key.") whether the key
|
||||
* doesn't exist, is deactivated, or the caller's IP isn't on its allowlist -- so a probing attacker
|
||||
* cannot use the response to tell a wrong key apart from a valid-but-blocked one.</li>
|
||||
* <li>The IP allowlist is a plain comma-separated exact-match list, not CIDR ranges -- adequate for a
|
||||
* small, known set of partner integrations; if a partner calls from a rotating pool of addresses, leave
|
||||
* {@code allowedIps} null for that key rather than trying to enumerate every address.</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class ApiClientCredentialService {
|
||||
|
||||
private static final String GENERIC_AUTH_FAILURE = "Invalid or inactive API key.";
|
||||
// Enough random bytes that guessing is infeasible (256 bits) -- the same order of magnitude as a
|
||||
// session token, appropriate for a credential that never expires on its own.
|
||||
private static final int KEY_ENTROPY_BYTES = 32;
|
||||
|
||||
@Qualifier("hikariTenantDataSource")
|
||||
private final DataSource tenantStoreDataSource;
|
||||
private final SecureRandom secureRandom = new SecureRandom();
|
||||
|
||||
// Built once from the qualified DataSource in @PostConstruct rather than injected directly as a
|
||||
// JdbcTemplate: @RequiredArgsConstructor only wires constructor parameters straight from fields,
|
||||
// and JdbcTemplate itself isn't a bean registered under this qualifier -- only the DataSource is.
|
||||
private JdbcTemplate tenantStoreJdbc;
|
||||
|
||||
@PostConstruct
|
||||
void init() {
|
||||
this.tenantStoreJdbc = new JdbcTemplate(tenantStoreDataSource);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a raw {@code X-Api-Key} header value. On success, returns the registered client's
|
||||
* display name (for the caller to log/attribute the request to) and records this call -- including
|
||||
* which tenant it was made against -- as the key's most recent use. On any failure, throws a clean
|
||||
* 401; the caller never gets far enough to tell a wrong key from a deactivated one or a blocked IP.
|
||||
*/
|
||||
public String authenticate(final String rawApiKey, final String callerIp) {
|
||||
if (StringUtils.isBlank(rawApiKey)) {
|
||||
throw unauthorized();
|
||||
}
|
||||
final String hash = sha256Hex(rawApiKey);
|
||||
final List<Map<String, Object>> rows = tenantStoreJdbc.queryForList("""
|
||||
SELECT id, client_name, is_active, allowed_ips FROM m_api_client WHERE api_key_hash = ?
|
||||
""", hash);
|
||||
if (rows.isEmpty()) {
|
||||
throw unauthorized();
|
||||
}
|
||||
final Map<String, Object> row = rows.get(0);
|
||||
if (!Boolean.TRUE.equals(row.get("is_active"))) {
|
||||
throw unauthorized();
|
||||
}
|
||||
final String allowedIps = (String) row.get("allowed_ips");
|
||||
if (StringUtils.isNotBlank(allowedIps) && !isIpAllowed(allowedIps, callerIp)) {
|
||||
throw unauthorized();
|
||||
}
|
||||
|
||||
final Long id = ((Number) row.get("id")).longValue();
|
||||
tenantStoreJdbc.update("UPDATE m_api_client SET last_used_at = ?, last_used_ip = ?, last_used_tenant = ? WHERE id = ?",
|
||||
java.sql.Timestamp.from(Instant.now()), callerIp, currentTenantId(), id);
|
||||
|
||||
return (String) row.get("client_name");
|
||||
}
|
||||
|
||||
private boolean isIpAllowed(final String allowedIps, final String callerIp) {
|
||||
if (StringUtils.isBlank(callerIp)) {
|
||||
return false;
|
||||
}
|
||||
return Arrays.stream(allowedIps.split(",")).map(String::trim).anyMatch(ip -> ip.equalsIgnoreCase(callerIp));
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin operation: mints a new key and returns it in the response -- the ONLY time the raw value
|
||||
* is ever available. {@code allowedIps} may be null/blank for no IP restriction. The resulting key
|
||||
* authenticates against every tenant, regardless of which one the admin minting it happened to be
|
||||
* logged into.
|
||||
*/
|
||||
public Map<String, Object> createClient(final String clientName, final String allowedIps, final Long createdByUserId) {
|
||||
if (StringUtils.isBlank(clientName)) {
|
||||
throw badRequest("clientName is required.");
|
||||
}
|
||||
final String rawKey = generateRawKey();
|
||||
final String hash = sha256Hex(rawKey);
|
||||
final String prefix = rawKey.substring(0, Math.min(12, rawKey.length()));
|
||||
|
||||
final Long id = tenantStoreJdbc.queryForObject("""
|
||||
INSERT INTO m_api_client (client_name, api_key_hash, api_key_prefix, allowed_ips, created_by, created_by_tenant)
|
||||
VALUES (?,?,?,?,?,?)
|
||||
RETURNING id
|
||||
""", Long.class, clientName, hash, prefix, StringUtils.isBlank(allowedIps) ? null : allowedIps, createdByUserId,
|
||||
currentTenantId());
|
||||
|
||||
final Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("id", id);
|
||||
result.put("clientName", clientName);
|
||||
result.put("apiKey", rawKey);
|
||||
result.put("warning", "This is the only time the full API key is shown. Store it now -- it cannot be retrieved again.");
|
||||
return result;
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> listClients() {
|
||||
return tenantStoreJdbc.queryForList("""
|
||||
SELECT id, client_name, api_key_prefix, is_active, allowed_ips,
|
||||
created_date, created_by_tenant, deactivated_date, last_used_at, last_used_ip, last_used_tenant
|
||||
FROM m_api_client ORDER BY created_date DESC
|
||||
""");
|
||||
}
|
||||
|
||||
public void setActive(final Long id, final boolean active) {
|
||||
final int updated = tenantStoreJdbc.update(
|
||||
"UPDATE m_api_client SET is_active = ?, deactivated_date = ? WHERE id = ?", active,
|
||||
active ? null : java.sql.Timestamp.from(Instant.now()), id);
|
||||
if (updated == 0) {
|
||||
throw notFound("No API client with id " + id);
|
||||
}
|
||||
}
|
||||
|
||||
private String currentTenantId() {
|
||||
try {
|
||||
return ThreadLocalContextUtil.getTenant().getTenantIdentifier();
|
||||
} catch (final RuntimeException e) {
|
||||
// Defensive only: every request through SecurityConfig's matcher chain has a tenant
|
||||
// resolved before it reaches here (see TenantAwareBasicAuthenticationFilter), so this
|
||||
// should be unreachable in practice.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String generateRawKey() {
|
||||
final byte[] bytes = new byte[KEY_ENTROPY_BYTES];
|
||||
secureRandom.nextBytes(bytes);
|
||||
// URL-safe, no padding: safe to put directly in a header value with no further encoding.
|
||||
return "cbsx_" + Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||
}
|
||||
|
||||
private String sha256Hex(final String raw) {
|
||||
try {
|
||||
final MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
final byte[] hashBytes = digest.digest(raw.getBytes(StandardCharsets.UTF_8));
|
||||
final StringBuilder hex = new StringBuilder(hashBytes.length * 2);
|
||||
for (final byte b : hashBytes) {
|
||||
hex.append(String.format("%02x", b));
|
||||
}
|
||||
return hex.toString();
|
||||
} catch (final NoSuchAlgorithmException e) {
|
||||
// SHA-256 is a mandatory JCE algorithm on every JVM distribution Fineract supports --
|
||||
// this can only mean a broken JVM install, not a recoverable runtime condition.
|
||||
throw new IllegalStateException("SHA-256 is unavailable in this JVM", e);
|
||||
}
|
||||
}
|
||||
|
||||
private WebApplicationException unauthorized() {
|
||||
// Naming the tenant the caller sent is safe here -- it is the header value they themselves
|
||||
// put on the request, not something this check reveals to them -- and it turns "why did my
|
||||
// key fail" from a guess into something checkable: was this call even reaching the bank the
|
||||
// caller thought it was?
|
||||
final String tenantId = currentTenantId();
|
||||
final String message = tenantId != null ? GENERIC_AUTH_FAILURE + " (tenant: " + tenantId + ")" : GENERIC_AUTH_FAILURE;
|
||||
return jsonError(401, message);
|
||||
}
|
||||
|
||||
private WebApplicationException badRequest(final String message) {
|
||||
return jsonError(400, message);
|
||||
}
|
||||
|
||||
private WebApplicationException notFound(final String message) {
|
||||
return jsonError(404, message);
|
||||
}
|
||||
|
||||
private WebApplicationException jsonError(final int status, final String message) {
|
||||
final String body = "{\"httpStatusCode\":\"" + status + "\",\"message\":\"" + message.replace("\"", "'") + "\"}";
|
||||
return new WebApplicationException(Response.status(status).entity(body).type(MediaType.APPLICATION_JSON).build());
|
||||
}
|
||||
}
|
||||
+330
@@ -0,0 +1,330 @@
|
||||
/**
|
||||
* 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.portfolio.client.api;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonObject;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.HeaderParam;
|
||||
import jakarta.ws.rs.NotFoundException;
|
||||
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 java.math.BigDecimal;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.time.LocalDate;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant;
|
||||
import org.apache.fineract.infrastructure.core.domain.JdbcSupport;
|
||||
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
|
||||
import org.apache.fineract.infrastructure.security.apiclient.ApiClientCredentialService;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.savings.data.SavingsAccountStatusEnumData;
|
||||
import org.apache.fineract.portfolio.savings.service.SavingsEnumerations;
|
||||
import org.apache.fineract.portfolio.shareaccounts.data.ShareAccountStatusEnumData;
|
||||
import org.apache.fineract.portfolio.shareaccounts.service.SharesEnumerations;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* A single client's full deposit + share picture, looked up by client number OR mobile number (the
|
||||
* path segment is tried as {@code m_client.account_no} first, then {@code m_client.mobile_no}) rather
|
||||
* than the internal client id -- these are what a counter clerk has on the slip, or what a third-party
|
||||
* caller was given, respectively, so no separate client-search step is needed before this call. Built
|
||||
* so the whole thing, including the client lookup itself, resolves in a small handful of index-scan-only
|
||||
* queries, on the theory that this is exactly the kind of check a teller or manager runs right before
|
||||
* approving something (a share purchase, a closure, a large withdrawal) -- the "is there a problem here"
|
||||
* question this endpoint answers has to come back before that decision is made, not after.
|
||||
*
|
||||
* Deliberately its own lightweight resource rather than a reuse of {@code AccountSummaryCollectionData}
|
||||
* (GET /clients/{clientId}/accounts): that endpoint's row mappers each carry six LEFT JOINs to
|
||||
* m_appuser for a full submitted/approved/rejected/activated/closed audit trail per account, which this
|
||||
* caller doesn't need -- account number, product, status and the current balance/share value is the
|
||||
* whole ask. Fewer joins and fewer columns is what actually makes this fast, not a different query
|
||||
* engine.
|
||||
*
|
||||
* Two ways in, deliberately: a normal Fineract user (Basic Auth, unchanged) can call this exactly as
|
||||
* they do every other endpoint; a third-party integration that should never see a Fineract username or
|
||||
* password sends an {@code X-Api-Key} header instead (see {@link ApiClientCredentialService}), minted
|
||||
* via {@code POST /v1/apiclients} by a super user. This path is carved out as {@code permitAll()} in
|
||||
* {@code SecurityConfig} for exactly that second case -- the same posture this fork already uses for the
|
||||
* IMPS and NPCI receivers, where Spring Security's outer gate stays open for one narrow path and the
|
||||
* handler itself is what actually authenticates the caller.
|
||||
*/
|
||||
@Path("/v1/clientaccounts")
|
||||
@Component
|
||||
@Tag(name = "Client All Accounts", description = "Fast, single-lookup view of every Savings/Fixed Deposit/Recurring Deposit/Current/Share account a client holds, by client number or mobile number.")
|
||||
@RequiredArgsConstructor
|
||||
public class ClientAllAccountsApiResource {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final PlatformSecurityContext context;
|
||||
private final ApiClientCredentialService apiClientCredentialService;
|
||||
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
||||
|
||||
// Same header-precedence list CallerIpTrackingFilter uses elsewhere in this fork, kept local
|
||||
// rather than depending on that filter bean: it's only conditionally registered
|
||||
// (fineract.ip-tracking.enabled), and an API-key IP allowlist has to work regardless of whether
|
||||
// that unrelated feature toggle is on.
|
||||
private static final String[] IP_HEADER_CANDIDATES = { "X-Forwarded-For", "Proxy-Client-IP", "WL-Proxy-Client-IP" };
|
||||
|
||||
// One UNION ALL, one round trip: SB/FD/RD (m_savings_account, split by deposit_type_enum),
|
||||
// CD (m_current_account, its own table -- current accounts were never folded into
|
||||
// m_savings_account here, see AccountDetailsReadPlatformServiceJpaRepositoryImpl for the same
|
||||
// split), and Share (m_share_account). Every leg filters on client_id, which is indexed on
|
||||
// all three tables (fksa00000000000001, idx_ca_client_id, m_share_account_ibfk_9) -- an
|
||||
// index-only lookup per leg, not a scan.
|
||||
private static final String ACCOUNTS_SQL = """
|
||||
SELECT sa.deposit_type_enum AS deposit_type, sa.id AS account_id, sa.account_no, sa.external_id,
|
||||
sa.status_enum AS status_enum, sa.currency_code, sa.product_id,
|
||||
p.name AS product_name, p.short_name AS short_product_name,
|
||||
sa.account_balance_derived AS balance, COALESCE(sa.on_hold_funds_derived, 0) AS on_hold,
|
||||
NULL::bigint AS approved_shares, NULL::bigint AS pending_shares, NULL::numeric(10,2) AS unit_price,
|
||||
sa.submittedon_date AS submitted_date, sa.activatedon_date AS activated_date
|
||||
FROM m_savings_account sa
|
||||
JOIN m_savings_product p ON p.id = sa.product_id
|
||||
WHERE sa.client_id = ?
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT CAST(400 AS smallint) AS deposit_type, ca.id, ca.account_no, ca.external_id,
|
||||
ca.status_enum, ca.currency_code, ca.product_id, cp.name, cp.short_name,
|
||||
ca.account_balance_derived, COALESCE(ca.on_hold_funds_derived, 0),
|
||||
NULL::bigint, NULL::bigint, NULL::numeric(10,2),
|
||||
ca.submittedon_date, ca.activatedon_date
|
||||
FROM m_current_account ca
|
||||
JOIN m_current_product cp ON cp.id = ca.product_id
|
||||
WHERE ca.client_id = ?
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT CAST(NULL AS smallint) AS deposit_type, shr.id, shr.account_no, shr.external_id,
|
||||
shr.status_enum, shr.currency_code, shr.product_id, sp.name, sp.short_name,
|
||||
NULL::numeric(19,6), NULL::numeric(19,6),
|
||||
shr.total_approved_shares, shr.total_pending_shares, sp.unit_price,
|
||||
shr.submitted_date, shr.activated_date
|
||||
FROM m_share_account shr
|
||||
JOIN m_share_product sp ON sp.id = shr.product_id
|
||||
WHERE shr.client_id = ?
|
||||
""";
|
||||
|
||||
@GET
|
||||
@Path("/{identifier}")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String retrieveByIdentifier(@PathParam("identifier") final String identifier,
|
||||
@HeaderParam("X-Api-Key") final String apiKey, @Context final HttpServletRequest servletRequest) {
|
||||
// Dual-mode: a header present means a third-party caller, checked against m_api_client and
|
||||
// never touching Fineract's own login. No header means an ordinary logged-in user -- exactly
|
||||
// the same authenticatedUser() gate every other resource in this codebase uses. The two are
|
||||
// mutually exclusive on purpose: a request carrying an API key is never also allowed to fall
|
||||
// back to "well, maybe they're logged in too" -- that would let a stolen key ride along on a
|
||||
// legitimate session's cookies/headers.
|
||||
if (StringUtils.isNotBlank(apiKey)) {
|
||||
apiClientCredentialService.authenticate(apiKey, resolveClientIp(servletRequest));
|
||||
} else {
|
||||
context.authenticatedUser();
|
||||
}
|
||||
|
||||
final FineractPlatformTenant tenant = ThreadLocalContextUtil.getTenant();
|
||||
final Map<String, Object> client = resolveClient(identifier, tenant);
|
||||
final Long clientId = ((Number) client.get("id")).longValue();
|
||||
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.query(ACCOUNTS_SQL, new AccountRowMapper(), clientId, clientId, clientId);
|
||||
|
||||
final Map<String, Object> totalsByCurrency = new LinkedHashMap<>();
|
||||
final Map<String, Integer> countByType = new LinkedHashMap<>();
|
||||
for (final Map<String, Object> row : rows) {
|
||||
final String accountType = (String) row.get("accountType");
|
||||
countByType.merge(accountType, 1, Integer::sum);
|
||||
|
||||
final Object valueObj = row.get("currentValue");
|
||||
if (valueObj instanceof BigDecimal value) {
|
||||
final String currency = (String) row.get("currencyCode");
|
||||
final BigDecimal running = (BigDecimal) totalsByCurrency.getOrDefault(currency, BigDecimal.ZERO);
|
||||
totalsByCurrency.put(currency, running.add(value));
|
||||
}
|
||||
}
|
||||
|
||||
final JsonObject res = new JsonObject();
|
||||
// Which bank this response came from -- not decorative. One API key now authenticates
|
||||
// against every tenant (see ApiClientCredentialService), so the ONLY thing that decides
|
||||
// whose data comes back is the Fineract-Platform-TenantId header the caller sent. Echoing it
|
||||
// (plus the bank's display name) back in the body means a caller debugging "why did I get
|
||||
// client X's data instead of client Y's" can tell from the response alone, without having to
|
||||
// go back and re-check what header value they actually sent.
|
||||
res.addProperty("tenantId", tenant.getTenantIdentifier());
|
||||
res.addProperty("tenantName", tenant.getName());
|
||||
res.addProperty("searchedIdentifier", identifier);
|
||||
res.addProperty("clientId", clientId);
|
||||
res.addProperty("matchedBy", (String) client.get("matched_by"));
|
||||
res.addProperty("clientNo", (String) client.get("account_no"));
|
||||
res.addProperty("clientName", (String) client.get("display_name"));
|
||||
res.addProperty("mobileNo", (String) client.get("mobile_no"));
|
||||
res.addProperty("officeId", ((Number) client.get("office_id")).longValue());
|
||||
res.addProperty("clientStatusEnum", ((Number) client.get("status_enum")).intValue());
|
||||
res.addProperty("totalAccounts", rows.size());
|
||||
res.add("accountsByType", gson.toJsonTree(countByType));
|
||||
res.add("totalCurrentValueByCurrency", gson.toJsonTree(totalsByCurrency));
|
||||
res.add("accounts", gson.toJsonTree(rows));
|
||||
return gson.toJson(res);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the path identifier as a client number first, then a mobile number -- two lookups in
|
||||
* the worst case, one in the common case, both on unique indexed columns
|
||||
* ({@code m_client_account_no_key}, {@code m_client_mobile_no_key} / {@code idx_client_mobile_no}),
|
||||
* so neither costs more than the single index-scan lookup this whole endpoint is built around.
|
||||
* Client number is tried first because it's the identifier a teller normally has on the slip in
|
||||
* front of them; falling through to mobile number covers the third-party case where that's the
|
||||
* only thing the caller was given. A value that happens to collide across both columns for two
|
||||
* different clients is not handled specially -- account numbers and mobile numbers use disjoint
|
||||
* formats in this system, so this cannot occur in practice.
|
||||
*
|
||||
* {@code tenant} is only for the not-found message: with one API key now valid across every bank
|
||||
* (see {@link ApiClientCredentialService}), a lookup that finds nothing is as often "right
|
||||
* identifier, wrong bank" (a stale or mistyped {@code Fineract-Platform-TenantId} header) as it is
|
||||
* a genuinely unknown client -- naming the bank actually searched turns that from a guessing game
|
||||
* into something the caller can act on immediately.
|
||||
*/
|
||||
private Map<String, Object> resolveClient(final String identifier, final FineractPlatformTenant tenant) {
|
||||
final List<Map<String, Object>> byAccountNo = jdbcTemplate.queryForList("""
|
||||
SELECT id, account_no, display_name, mobile_no, office_id, status_enum
|
||||
FROM m_client WHERE account_no = ?
|
||||
""", identifier);
|
||||
if (!byAccountNo.isEmpty()) {
|
||||
final Map<String, Object> client = new LinkedHashMap<>(byAccountNo.get(0));
|
||||
client.put("matched_by", "CLIENT_NO");
|
||||
return client;
|
||||
}
|
||||
|
||||
final List<Map<String, Object>> byMobileNo = jdbcTemplate.queryForList("""
|
||||
SELECT id, account_no, display_name, mobile_no, office_id, status_enum
|
||||
FROM m_client WHERE mobile_no = ?
|
||||
""", identifier);
|
||||
if (!byMobileNo.isEmpty()) {
|
||||
final Map<String, Object> client = new LinkedHashMap<>(byMobileNo.get(0));
|
||||
client.put("matched_by", "MOBILE_NO");
|
||||
return client;
|
||||
}
|
||||
|
||||
throw new NotFoundException("No client found with client number or mobile number '" + identifier + "' in tenant '"
|
||||
+ tenant.getTenantIdentifier() + "' (" + tenant.getName() + "). If you expected a match, check the "
|
||||
+ "Fineract-Platform-TenantId header -- this identifier may belong to a different bank.");
|
||||
}
|
||||
|
||||
private String resolveClientIp(final HttpServletRequest request) {
|
||||
for (final String header : IP_HEADER_CANDIDATES) {
|
||||
final String value = request.getHeader(header);
|
||||
if (StringUtils.isNotBlank(value)) {
|
||||
// X-Forwarded-For can carry a chain ("client, proxy1, proxy2") -- the first entry is
|
||||
// the original caller, which is what an allowlist entry is written against.
|
||||
return value.split(",")[0].trim();
|
||||
}
|
||||
}
|
||||
return request.getRemoteAddr();
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps one UNION leg row into a flat, display-ready shape -- category label, human status, and a
|
||||
* single {@code currentValue} figure so the caller never has to know that a deposit's value lives
|
||||
* in one column and a share account's lives in another (approved shares × unit price).
|
||||
*/
|
||||
private static final class AccountRowMapper implements RowMapper<Map<String, Object>> {
|
||||
|
||||
@Override
|
||||
public Map<String, Object> mapRow(final ResultSet rs, final int rowNum) throws SQLException {
|
||||
final Integer depositType = JdbcSupport.getInteger(rs, "deposit_type");
|
||||
final Integer statusEnum = JdbcSupport.getInteger(rs, "status_enum");
|
||||
final BigDecimal balance = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "balance");
|
||||
final BigDecimal onHold = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "on_hold");
|
||||
final Long approvedShares = JdbcSupport.getLong(rs, "approved_shares");
|
||||
final Long pendingShares = JdbcSupport.getLong(rs, "pending_shares");
|
||||
final BigDecimal unitPrice = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "unit_price");
|
||||
|
||||
final boolean isShare = depositType == null;
|
||||
final String accountType = isShare ? "Share Account" : accountTypeFor(depositType);
|
||||
|
||||
final Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("accountType", accountType);
|
||||
row.put("accountId", rs.getLong("account_id"));
|
||||
row.put("accountNo", rs.getString("account_no"));
|
||||
row.put("externalId", rs.getString("external_id"));
|
||||
row.put("productId", rs.getLong("product_id"));
|
||||
row.put("productName", rs.getString("product_name"));
|
||||
row.put("shortProductName", rs.getString("short_product_name"));
|
||||
row.put("currencyCode", rs.getString("currency_code"));
|
||||
// .toString(), not the raw LocalDate: this Gson instance has no java.time adapter
|
||||
// registered, so it falls back to reflecting LocalDate's private fields -- which the JVM
|
||||
// module system blocks outright (java.base does not open java.time to reflection from an
|
||||
// unnamed module), turning every response into a 500. A plain ISO-8601 string needs no
|
||||
// adapter and is what every other date field in this API already returns.
|
||||
row.put("submittedDate", toIsoDateString(JdbcSupport.getLocalDate(rs, "submitted_date")));
|
||||
row.put("activatedDate", toIsoDateString(JdbcSupport.getLocalDate(rs, "activated_date")));
|
||||
|
||||
if (isShare) {
|
||||
final ShareAccountStatusEnumData status = SharesEnumerations.status(statusEnum);
|
||||
row.put("status", status);
|
||||
row.put("approvedShares", approvedShares);
|
||||
row.put("pendingShares", pendingShares);
|
||||
row.put("unitPrice", unitPrice);
|
||||
final BigDecimal shareValue = (approvedShares != null && unitPrice != null)
|
||||
? unitPrice.multiply(BigDecimal.valueOf(approvedShares))
|
||||
: null;
|
||||
row.put("currentValue", shareValue);
|
||||
} else {
|
||||
final SavingsAccountStatusEnumData status = SavingsEnumerations.status(statusEnum);
|
||||
row.put("status", status);
|
||||
row.put("balance", balance);
|
||||
row.put("onHoldFunds", onHold);
|
||||
BigDecimal available = balance;
|
||||
if (available != null && onHold != null) {
|
||||
available = available.subtract(onHold);
|
||||
}
|
||||
row.put("availableBalance", available);
|
||||
row.put("currentValue", balance);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
private String accountTypeFor(final int depositType) {
|
||||
return switch (depositType) {
|
||||
case 100 -> "Savings Account";
|
||||
case 200 -> "Fixed Deposit";
|
||||
case 300 -> "Recurring Deposit";
|
||||
case 400 -> "Current Account";
|
||||
default -> "Other";
|
||||
};
|
||||
}
|
||||
|
||||
private String toIsoDateString(final LocalDate date) {
|
||||
return date == null ? null : date.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -31,4 +31,5 @@
|
||||
<include file="parts/0008_encrypt_existing_ro_tenant_passwords.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0009_set_and_encrypt_ro_if_not_exists.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0010_set_datetime_precision.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0011_create_api_client.xml" relativeToChangelogFile="true"/>
|
||||
</databaseChangeLog>
|
||||
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<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">
|
||||
|
||||
<!-- Registry of third-party API keys, for endpoints that must be callable without a Fineract user
|
||||
login (e.g. GET /v1/clientaccounts). Lives in the tenant-STORE database (fineract_tenants),
|
||||
not any one bank's own tenant database: a key is a caller identity, independent of which
|
||||
tenant's data a given request asks for, and a key minted today must keep working for every
|
||||
tenant that exists now AND every one provisioned later: a per-tenant copy of this table
|
||||
would need a manual re-insert for each new bank, and would still need one row per tenant for
|
||||
every key, which is exactly what a central table avoids.
|
||||
|
||||
Only a SHA-256 hash of the key is stored, never the raw key: a leaked database backup can
|
||||
never be turned back into a usable credential. api_key_prefix keeps the first 12 characters
|
||||
of the raw key alongside the hash purely so an admin can tell two keys apart in a listing: it
|
||||
is not, by itself, enough to authenticate. -->
|
||||
<changeSet id="0011_create_api_client" author="cbs-nitesh">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not>
|
||||
<tableExists tableName="m_api_client"/>
|
||||
</not>
|
||||
</preConditions>
|
||||
<createTable tableName="m_api_client">
|
||||
<column name="id" type="BIGSERIAL">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="client_name" type="VARCHAR(200)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="api_key_hash" type="VARCHAR(64)">
|
||||
<constraints nullable="false" unique="true"/>
|
||||
</column>
|
||||
<column name="api_key_prefix" type="VARCHAR(12)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="is_active" type="BOOLEAN" defaultValueBoolean="true">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<!-- Comma-separated allowlist. NULL = no IP restriction (key alone is enough). -->
|
||||
<column name="allowed_ips" type="VARCHAR(500)"/>
|
||||
<column name="created_by" type="BIGINT"/>
|
||||
<column name="created_by_tenant" type="VARCHAR(100)"/>
|
||||
<column name="created_date" type="TIMESTAMP WITHOUT TIME ZONE" defaultValueComputed="CURRENT_TIMESTAMP">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="deactivated_date" type="TIMESTAMP WITHOUT TIME ZONE"/>
|
||||
<column name="last_used_at" type="TIMESTAMP WITHOUT TIME ZONE"/>
|
||||
<column name="last_used_ip" type="VARCHAR(64)"/>
|
||||
<column name="last_used_tenant" type="VARCHAR(100)"/>
|
||||
</createTable>
|
||||
|
||||
<createIndex indexName="idx_api_client_hash" tableName="m_api_client">
|
||||
<column name="api_key_hash"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
Reference in New Issue
Block a user