diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java index c740cf276..7085dc8d1 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java @@ -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() diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/apiclient/ApiClientAdminApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/apiclient/ApiClientAdminApiResource.java new file mode 100644 index 000000000..0c469621a --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/apiclient/ApiClientAdminApiResource.java @@ -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 result = credentialService.createClient(clientName, allowedIps, user.getId()); + return gson.toJson(result); + } + + @GET + @Produces(MediaType.APPLICATION_JSON) + public String list() { + requireSuperUser(); + final List> 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; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/apiclient/ApiClientCredentialService.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/apiclient/ApiClientCredentialService.java new file mode 100644 index 000000000..f4b6d4bf3 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/apiclient/ApiClientCredentialService.java @@ -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: + * + */ +@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> 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 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 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 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> 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()); + } +} diff --git a/fineract-provider/src/main/resources/db/changelog/tenant-store/changelog-tenant-store.xml b/fineract-provider/src/main/resources/db/changelog/tenant-store/changelog-tenant-store.xml index de38521da..852255252 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant-store/changelog-tenant-store.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant-store/changelog-tenant-store.xml @@ -31,4 +31,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant-store/parts/0011_create_api_client.xml b/fineract-provider/src/main/resources/db/changelog/tenant-store/parts/0011_create_api_client.xml new file mode 100644 index 000000000..a63cae5f0 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant-store/parts/0011_create_api_client.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +