From 50403f75486ccd6e1b7eec62a1722b0384ba305e Mon Sep 17 00:00:00 2001 From: Abhishek Kumar Date: Fri, 20 Mar 2026 15:14:36 +0530 Subject: [PATCH] changes for allowed cidrs; refactor Signed-off-by: Abhishek Kumar --- .../cloudstack/veeam/VeeamControlServer.java | 15 ++- .../cloudstack/veeam/VeeamControlService.java | 15 ++- .../veeam/VeeamControlServiceImpl.java | 59 ++++++++-- .../filter/AllowedClientCidrsFilter.java | 100 +++++++++++++++++ .../veeam/filter/BearerOrBasicAuthFilter.java | 85 ++++++--------- .../cloudstack/veeam/sso/SsoService.java | 103 +++++------------- .../cloudstack/veeam/utils/DataUtil.java | 44 ++++++++ .../cloudstack/veeam/utils/JwtUtil.java | 57 ++++++++++ 8 files changed, 334 insertions(+), 144 deletions(-) create mode 100644 plugins/integrations/veeam-control-service/src/main/java/org/apache/cloudstack/veeam/filter/AllowedClientCidrsFilter.java create mode 100644 plugins/integrations/veeam-control-service/src/main/java/org/apache/cloudstack/veeam/utils/DataUtil.java create mode 100644 plugins/integrations/veeam-control-service/src/main/java/org/apache/cloudstack/veeam/utils/JwtUtil.java diff --git a/plugins/integrations/veeam-control-service/src/main/java/org/apache/cloudstack/veeam/VeeamControlServer.java b/plugins/integrations/veeam-control-service/src/main/java/org/apache/cloudstack/veeam/VeeamControlServer.java index adf9e45ecdf..3121fd6ecf4 100644 --- a/plugins/integrations/veeam-control-service/src/main/java/org/apache/cloudstack/veeam/VeeamControlServer.java +++ b/plugins/integrations/veeam-control-service/src/main/java/org/apache/cloudstack/veeam/VeeamControlServer.java @@ -29,6 +29,7 @@ import javax.servlet.http.HttpServletRequest; import org.apache.cloudstack.utils.server.ServerPropertiesUtil; import org.apache.cloudstack.veeam.api.ApiService; +import org.apache.cloudstack.veeam.filter.AllowedClientCidrsFilter; import org.apache.cloudstack.veeam.filter.BearerOrBasicAuthFilter; import org.apache.commons.lang3.StringUtils; import org.apache.logging.log4j.LogManager; @@ -43,6 +44,7 @@ import org.eclipse.jetty.server.ServerConnector; import org.eclipse.jetty.server.SslConnectionFactory; import org.eclipse.jetty.server.handler.HandlerList; import org.eclipse.jetty.server.handler.RequestLogHandler; +import org.eclipse.jetty.servlet.FilterHolder; import org.eclipse.jetty.servlet.ServletContextHandler; import org.eclipse.jetty.servlet.ServletHolder; import org.eclipse.jetty.util.ssl.SslContextFactory; @@ -51,12 +53,14 @@ import org.jetbrains.annotations.NotNull; public class VeeamControlServer { private static final Logger LOGGER = LogManager.getLogger(VeeamControlServer.class); + private final VeeamControlService veeamControlService; private Server server; private List routeHandlers; - public VeeamControlServer(List routeHandlers) { + public VeeamControlServer(List routeHandlers, VeeamControlService veeamControlService) { this.routeHandlers = new ArrayList<>(routeHandlers); this.routeHandlers.sort((a, b) -> Integer.compare(b.priority(), a.priority())); + this.veeamControlService = veeamControlService; } public void startIfEnabled() throws Exception { @@ -118,8 +122,15 @@ public class VeeamControlServer { new ServletContextHandler(ServletContextHandler.NO_SESSIONS); ctx.setContextPath(ctxPath); + // CIDR filter for all routes + AllowedClientCidrsFilter cidrFilter = new AllowedClientCidrsFilter(veeamControlService); + FilterHolder cidrHolder = new FilterHolder(cidrFilter); + ctx.addFilter(cidrHolder, ApiService.BASE_ROUTE + "/*", EnumSet.of(DispatcherType.REQUEST)); + // Bearer or Basic Auth for all routes - ctx.addFilter(BearerOrBasicAuthFilter.class, ApiService.BASE_ROUTE + "/*", EnumSet.of(DispatcherType.REQUEST)); + BearerOrBasicAuthFilter authFilter = new BearerOrBasicAuthFilter(veeamControlService); + FilterHolder authHolder = new FilterHolder(authFilter); + ctx.addFilter(authHolder, ApiService.BASE_ROUTE + "/*", EnumSet.of(DispatcherType.REQUEST)); // Front controller servlet ctx.addServlet(new ServletHolder(new VeeamControlServlet(routeHandlers)), "/*"); diff --git a/plugins/integrations/veeam-control-service/src/main/java/org/apache/cloudstack/veeam/VeeamControlService.java b/plugins/integrations/veeam-control-service/src/main/java/org/apache/cloudstack/veeam/VeeamControlService.java index 38e350d5999..8e4abef9743 100644 --- a/plugins/integrations/veeam-control-service/src/main/java/org/apache/cloudstack/veeam/VeeamControlService.java +++ b/plugins/integrations/veeam-control-service/src/main/java/org/apache/cloudstack/veeam/VeeamControlService.java @@ -17,6 +17,8 @@ package org.apache.cloudstack.veeam; +import java.util.List; + import org.apache.cloudstack.framework.config.ConfigKey; import org.apache.cloudstack.framework.config.Configurable; @@ -31,9 +33,9 @@ public interface VeeamControlService extends PluggableService, Configurable { "8090", "Port for Veeam Integration REST API server", false); ConfigKey ContextPath = new ConfigKey<>("Advanced", String.class, "integration.veeam.control.context.path", "/ovirt-engine", "Context path for Veeam Integration REST API server", false); - ConfigKey Username = new ConfigKey<>("Advanced", String.class, "integration.veeam.control.api.username", + ConfigKey Username = new ConfigKey<>("Secure", String.class, "integration.veeam.control.api.username", "veeam", "Username for Basic Auth on Veeam Integration REST API server", true); - ConfigKey Password = new ConfigKey<>("Advanced", String.class, "integration.veeam.control.api.password", + ConfigKey Password = new ConfigKey<>("Secure", String.class, "integration.veeam.control.api.password", "change-me", "Password for Basic Auth on Veeam Integration REST API server", true); ConfigKey ServiceAccountId = new ConfigKey<>("Advanced", String.class, "integration.veeam.control.service.account", "", @@ -46,4 +48,13 @@ public interface VeeamControlService extends PluggableService, Configurable { "false", "Attempt to assign restored Instance to the owner based on OVF and network " + "details. If the assignment fails or set to false then the Instance will remain owned by the service " + "account", true); + ConfigKey AllowedClientCidrs = new ConfigKey<>("Advanced", String.class, + "integration.veeam.control.allowed.client.cidrs", + "", "Comma-separated list of CIDR blocks representing clients allowed to access the API. " + + "If empty, all clients will be allowed. Example: '192.168.1.1/24,192.168.2.100/32", true); + + + List getAllowedClientCidrs(); + + boolean validateCredentials(String username, String password); } diff --git a/plugins/integrations/veeam-control-service/src/main/java/org/apache/cloudstack/veeam/VeeamControlServiceImpl.java b/plugins/integrations/veeam-control-service/src/main/java/org/apache/cloudstack/veeam/VeeamControlServiceImpl.java index 683d0052f9d..a00d6bd5b83 100644 --- a/plugins/integrations/veeam-control-service/src/main/java/org/apache/cloudstack/veeam/VeeamControlServiceImpl.java +++ b/plugins/integrations/veeam-control-service/src/main/java/org/apache/cloudstack/veeam/VeeamControlServiceImpl.java @@ -17,17 +17,43 @@ package org.apache.cloudstack.veeam; +import java.util.Collections; import java.util.List; +import java.util.stream.Collectors; import org.apache.cloudstack.framework.config.ConfigKey; +import org.apache.cloudstack.utils.cache.SingleCache; +import org.apache.cloudstack.veeam.utils.DataUtil; +import org.apache.commons.lang3.StringUtils; import com.cloud.utils.component.ManagerBase; +import com.cloud.utils.net.NetUtils; public class VeeamControlServiceImpl extends ManagerBase implements VeeamControlService { private List routeHandlers; - private VeeamControlServer veeamControlServer; + private SingleCache> allowedClientCidrsCache; + + protected List getAllowedClientCidrsInternal() { + String allowedClientCidrsStr = AllowedClientCidrs.value(); + if (StringUtils.isBlank(allowedClientCidrsStr)) { + return Collections.emptyList(); + } + List allowedClientCidrs = List.of(allowedClientCidrsStr.split(",")); + // Sanitize and remove any incorrect CIDR entries + allowedClientCidrs = allowedClientCidrs.stream() + .map(String::trim) + .filter(StringUtils::isNotBlank) + .filter(cidr -> { + boolean valid = NetUtils.isValidIp4Cidr(cidr); + if (!valid) { + logger.warn("Invalid CIDR entry '{}' in allowed client CIDRs, ignoring", cidr); + } + return valid; + }).collect(Collectors.toList()); + return allowedClientCidrs; + } public List getRouteHandlers() { return routeHandlers; @@ -37,9 +63,21 @@ public class VeeamControlServiceImpl extends ManagerBase implements VeeamControl this.routeHandlers = routeHandlers; } + @Override + public List getAllowedClientCidrs() { + return allowedClientCidrsCache.get(); + } + + @Override + public boolean validateCredentials(String username, String password) { + return DataUtil.constantTimeEquals(Username.value(), username) && + DataUtil.constantTimeEquals(Password.value(), password); + } + @Override public boolean start() { - veeamControlServer = new VeeamControlServer(getRouteHandlers()); + allowedClientCidrsCache = new SingleCache<>(30, this::getAllowedClientCidrsInternal); + veeamControlServer = new VeeamControlServer(getRouteHandlers(), this); try { veeamControlServer.startIfEnabled(); } catch (Exception e) { @@ -71,14 +109,15 @@ public class VeeamControlServiceImpl extends ManagerBase implements VeeamControl @Override public ConfigKey[] getConfigKeys() { return new ConfigKey[] { - Enabled, - BindAddress, - Port, - ContextPath, - Username, - Password, - ServiceAccountId, - InstanceRestoreAssignOwner + Enabled, + BindAddress, + Port, + ContextPath, + Username, + Password, + ServiceAccountId, + InstanceRestoreAssignOwner, + AllowedClientCidrs }; } } diff --git a/plugins/integrations/veeam-control-service/src/main/java/org/apache/cloudstack/veeam/filter/AllowedClientCidrsFilter.java b/plugins/integrations/veeam-control-service/src/main/java/org/apache/cloudstack/veeam/filter/AllowedClientCidrsFilter.java new file mode 100644 index 00000000000..9c3c199704e --- /dev/null +++ b/plugins/integrations/veeam-control-service/src/main/java/org/apache/cloudstack/veeam/filter/AllowedClientCidrsFilter.java @@ -0,0 +1,100 @@ +// 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.cloudstack.veeam.filter; + +import java.io.IOException; +import java.net.InetAddress; +import java.util.List; + +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.FilterConfig; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.apache.cloudstack.veeam.VeeamControlService; +import org.apache.commons.collections.CollectionUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import com.cloud.utils.net.NetUtils; + +public class AllowedClientCidrsFilter implements Filter { + + private static final Logger LOGGER = LogManager.getLogger(AllowedClientCidrsFilter.class); + + private final VeeamControlService veeamControlService; + + public AllowedClientCidrsFilter(VeeamControlService veeamControlService) { + this.veeamControlService = veeamControlService; + } + + @Override + public void init(FilterConfig filterConfig) { + // no-op + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + if (!(request instanceof HttpServletRequest) || !(response instanceof HttpServletResponse)) { + chain.doFilter(request, response); + return; + } + + final HttpServletRequest req = (HttpServletRequest) request; + final HttpServletResponse resp = (HttpServletResponse) response; + + if (veeamControlService == null) { + LOGGER.warn("Failed to inject VeeamControlService, allowing request by default"); + chain.doFilter(request, response); + return; + } + + final List cidrList = veeamControlService.getAllowedClientCidrs(); + if (CollectionUtils.isEmpty(cidrList)) { + chain.doFilter(request, response); + return; + } + + final String remoteAddr = req.getRemoteAddr(); + try { + final InetAddress clientIp = InetAddress.getByName(remoteAddr); + final boolean allowed = NetUtils.isIpInCidrList(clientIp, cidrList.toArray(new String[0])); + if (!allowed) { + LOGGER.warn("Rejected request from client IP {} not in allowed CIDRs {}", remoteAddr, cidrList); + resp.sendError(HttpServletResponse.SC_FORBIDDEN, "Forbidden"); + return; + } + } catch (Exception e) { + LOGGER.warn("Rejected request failed to parse client IP {}: {}", remoteAddr, e.getMessage()); + resp.sendError(HttpServletResponse.SC_FORBIDDEN, "Forbidden"); + return; + } + + chain.doFilter(request, response); + } + + @Override + public void destroy() { + // no-op + } +} diff --git a/plugins/integrations/veeam-control-service/src/main/java/org/apache/cloudstack/veeam/filter/BearerOrBasicAuthFilter.java b/plugins/integrations/veeam-control-service/src/main/java/org/apache/cloudstack/veeam/filter/BearerOrBasicAuthFilter.java index 511e89ec68c..e86bd6a2a3e 100644 --- a/plugins/integrations/veeam-control-service/src/main/java/org/apache/cloudstack/veeam/filter/BearerOrBasicAuthFilter.java +++ b/plugins/integrations/veeam-control-service/src/main/java/org/apache/cloudstack/veeam/filter/BearerOrBasicAuthFilter.java @@ -21,11 +21,8 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; import java.time.Instant; import java.util.Base64; -import java.util.List; import java.util.Map; -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; import javax.servlet.Filter; import javax.servlet.FilterChain; import javax.servlet.FilterConfig; @@ -36,22 +33,30 @@ import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import org.apache.cloudstack.veeam.VeeamControlService; +import org.apache.cloudstack.veeam.sso.SsoService; +import org.apache.cloudstack.veeam.utils.DataUtil; +import org.apache.cloudstack.veeam.utils.JwtUtil; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; public class BearerOrBasicAuthFilter implements Filter { - - // Keep these aligned with SsoService (move to ConfigKeys later) - public static final List REQUIRED_SCOPES = List.of("ovirt-app-admin", "ovirt-app-portal"); - public static final String ISSUER = "veeam-control"; - public static final String HMAC_SECRET = "change-this-super-secret-key-change-this"; - private static final ObjectMapper JSON_MAPPER = new ObjectMapper(); - @Override public void init(FilterConfig filterConfig) {} - @Override public void destroy() {} + private final VeeamControlService veeamControlService; + + public BearerOrBasicAuthFilter(VeeamControlService veeamControlService) { + this.veeamControlService = veeamControlService; + } + + @Override + public void init(FilterConfig filterConfig) { + } + + @Override + public void destroy() { + } @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) @@ -89,9 +94,6 @@ public class BearerOrBasicAuthFilter implements Filter { } private boolean verifyBasic(String b64) { - final String expectedUser = VeeamControlService.Username.value(); - final String expectedPass = VeeamControlService.Password.value(); - final String decoded; try { decoded = new String(Base64.getDecoder().decode(b64), StandardCharsets.UTF_8); @@ -105,7 +107,7 @@ public class BearerOrBasicAuthFilter implements Filter { final String user = decoded.substring(0, idx); final String pass = decoded.substring(idx + 1); - return constantTimeEquals(user, expectedUser) && constantTimeEquals(pass, expectedPass); + return veeamControlService != null && veeamControlService.validateCredentials(user, pass); } /** @@ -114,9 +116,6 @@ public class BearerOrBasicAuthFilter implements Filter { * - "iss" matches * - "exp" not expired * - "scope" contains REQUIRED_SCOPES (space-separated) - * - * NOTE: This does not parse JSON robustly; it’s sufficient for the token you mint in SsoService. - * If you want robust parsing, switch to Nimbus and keep the rest the same. */ private boolean verifyJwtHs256(String token) { final String[] parts = token.split("\\."); @@ -128,8 +127,8 @@ public class BearerOrBasicAuthFilter implements Filter { final byte[] expectedSig; try { - expectedSig = hmacSha256((headerB64 + "." + payloadB64).getBytes(StandardCharsets.UTF_8), - HMAC_SECRET.getBytes(StandardCharsets.UTF_8)); + expectedSig = JwtUtil.hmacSha256((headerB64 + "." + payloadB64).getBytes(StandardCharsets.UTF_8), + SsoService.HMAC_SECRET.getBytes(StandardCharsets.UTF_8)); } catch (Exception e) { return false; } @@ -141,21 +140,22 @@ public class BearerOrBasicAuthFilter implements Filter { return false; } - if (!constantTimeEquals(expectedSig, providedSig)) return false; + if (!DataUtil.constantTimeEquals(expectedSig, providedSig)) return false; Map payloadMap; try { String payloadJson = new String(Base64.getUrlDecoder().decode(payloadB64), StandardCharsets.UTF_8); payloadMap = JSON_MAPPER.readValue( payloadJson, - new TypeReference<>() {} + new TypeReference<>() { + } ); } catch (IllegalArgumentException | JsonProcessingException e) { return false; } - final String iss = (String)payloadMap.get("iss"); - final String scope = (String)payloadMap.get("scope"); + final String iss = (String) payloadMap.get("iss"); + final String scope = (String) payloadMap.get("scope"); final Object expObj = payloadMap.get("exp"); Long exp = null; if (expObj instanceof Number) { @@ -163,10 +163,11 @@ public class BearerOrBasicAuthFilter implements Filter { } else if (expObj instanceof String) { try { exp = Long.parseLong((String) expObj); - } catch (NumberFormatException ignored) {} + } catch (NumberFormatException ignored) { + } } - if (!ISSUER.equals(iss)) { + if (!JwtUtil.ISSUER.equals(iss)) { return false; } if (exp == null || Instant.now().getEpochSecond() >= exp) { @@ -177,7 +178,7 @@ public class BearerOrBasicAuthFilter implements Filter { private static boolean hasRequiredScopes(String scope) { String[] scopes = scope.split("\\s+"); - for (String required : REQUIRED_SCOPES) { + for (String required : SsoService.REQUIRED_SCOPES) { if (!hasScope(scopes, required)) return false; } return true; @@ -192,22 +193,15 @@ public class BearerOrBasicAuthFilter implements Filter { return false; } - private static byte[] hmacSha256(byte[] data, byte[] key) throws Exception { - final Mac mac = Mac.getInstance("HmacSHA256"); - mac.init(new SecretKeySpec(key, "HmacSHA256")); - return mac.doFinal(data); - } - private static void unauthorized(HttpServletRequest req, HttpServletResponse resp, String error, String desc) throws IOException { - - // IMPORTANT: don’t throw (your current filter throws and Jetty turns it into 500) :contentReference[oaicite:3]{index=3} resp.resetBuffer(); resp.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // Helpful for OAuth clients: resp.setHeader("WWW-Authenticate", - "Bearer realm=\"Veeam Integration\", error=\"" + esc(error) + "\", error_description=\"" + esc(desc) + "\""); + "Bearer realm=\"Veeam Integration\", error=\"" + DataUtil.jsonEscape(error) + + "\", error_description=\"" + DataUtil.jsonEscape(desc) + "\""); final String accept = req.getHeader("Accept"); final boolean wantsJson = accept != null && accept.toLowerCase().contains("application/json"); @@ -215,27 +209,12 @@ public class BearerOrBasicAuthFilter implements Filter { resp.setCharacterEncoding("UTF-8"); if (wantsJson) { resp.setContentType("application/json; charset=UTF-8"); - resp.getWriter().write("{\"error\":\"" + esc(error) + "\",\"error_description\":\"" + esc(desc) + "\"}"); + resp.getWriter().write("{\"error\":\"" + DataUtil.jsonEscape(error) + + "\",\"error_description\":\"" + DataUtil.jsonEscape(desc) + "\"}"); } else { resp.setContentType("text/html; charset=UTF-8"); resp.getWriter().write("ErrorUnauthorized"); } resp.getWriter().flush(); } - - private static String esc(String s) { - return s == null ? "" : s.replace("\\", "\\\\").replace("\"", "\\\""); - } - - private static boolean constantTimeEquals(String a, String b) { - if (a == null || b == null) return false; - return constantTimeEquals(a.getBytes(StandardCharsets.UTF_8), b.getBytes(StandardCharsets.UTF_8)); - } - - private static boolean constantTimeEquals(byte[] x, byte[] y) { - if (x.length != y.length) return false; - int r = 0; - for (int i = 0; i < x.length; i++) r |= x[i] ^ y[i]; - return r == 0; - } } diff --git a/plugins/integrations/veeam-control-service/src/main/java/org/apache/cloudstack/veeam/sso/SsoService.java b/plugins/integrations/veeam-control-service/src/main/java/org/apache/cloudstack/veeam/sso/SsoService.java index a402b88ab76..3f173595201 100644 --- a/plugins/integrations/veeam-control-service/src/main/java/org/apache/cloudstack/veeam/sso/SsoService.java +++ b/plugins/integrations/veeam-control-service/src/main/java/org/apache/cloudstack/veeam/sso/SsoService.java @@ -20,27 +20,30 @@ package org.apache.cloudstack.veeam.sso; import java.io.IOException; import java.time.Instant; import java.util.HashMap; +import java.util.List; import java.util.Map; -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; +import javax.inject.Inject; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import org.apache.cloudstack.veeam.RouteHandler; import org.apache.cloudstack.veeam.VeeamControlService; import org.apache.cloudstack.veeam.VeeamControlServlet; -import org.apache.cloudstack.veeam.filter.BearerOrBasicAuthFilter; +import org.apache.cloudstack.veeam.utils.JwtUtil; import org.apache.cloudstack.veeam.utils.Negotiation; +import org.apache.commons.lang3.StringUtils; import com.cloud.utils.component.ManagerBase; public class SsoService extends ManagerBase implements RouteHandler { private static final String BASE_ROUTE = "/sso"; private static final long DEFAULT_TTL_SECONDS = 3600; + public static final List REQUIRED_SCOPES = List.of("ovirt-app-admin", "ovirt-app-portal"); + public static final String HMAC_SECRET = "change-this-super-secret-key-change-this"; - // Replace with your real credential validation (CloudStack account, config, etc.) - private final PasswordAuthenticator authenticator = new StaticPasswordAuthenticator(); + @Inject + VeeamControlService veeamControlService; @Override public boolean canHandle(String method, String path) { @@ -48,7 +51,8 @@ public class SsoService extends ManagerBase implements RouteHandler { } @Override - public void handle(HttpServletRequest req, HttpServletResponse resp, String path, Negotiation.OutFormat outFormat, VeeamControlServlet io) throws IOException { + public void handle(HttpServletRequest req, HttpServletResponse resp, String path, Negotiation.OutFormat outFormat, + VeeamControlServlet io) throws IOException { final String sanitizedPath = getSanitizedPath(path); if (sanitizedPath.equals(BASE_ROUTE + "/oauth/token")) { handleToken(req, resp, outFormat, io); @@ -62,54 +66,56 @@ public class SsoService extends ManagerBase implements RouteHandler { Negotiation.OutFormat outFormat, VeeamControlServlet io) throws IOException { if (!"POST".equalsIgnoreCase(req.getMethod())) { - // oVirt-like: 405 is fine; if you strictly want 400, change to SC_BAD_REQUEST resp.setHeader("Allow", "POST"); io.getWriter().write(resp, HttpServletResponse.SC_METHOD_NOT_ALLOWED, - Map.of("error", "method_not_allowed", "message", "token endpoint requires POST"), outFormat); + Map.of("error", "method_not_allowed", + "message", "token endpoint requires POST"), outFormat); return; } - // OAuth password grant uses x-www-form-urlencoded. With servlet containers this usually populates getParameter(). final String grantType = trimToNull(req.getParameter("grant_type")); - final String scope = trimToNull(req.getParameter("scope")); // typically "ovirt-app-api" + final String scope = trimToNull(req.getParameter("scope")); final String username = trimToNull(req.getParameter("username")); final String password = trimToNull(req.getParameter("password")); if (grantType == null) { io.getWriter().write(resp, HttpServletResponse.SC_BAD_REQUEST, - Map.of("error", "invalid_request", "error_description", "Missing parameter: grant_type"), outFormat); + Map.of("error", "invalid_request", + "error_description", "Missing parameter: grant_type"), outFormat); return; } if (!"password".equals(grantType)) { io.getWriter().write(resp, HttpServletResponse.SC_BAD_REQUEST, - Map.of("error", "unsupported_grant_type", "error_description", "Only grant_type=password is supported"), outFormat); + Map.of("error", "unsupported_grant_type", + "error_description", "Only grant_type=password is supported"), outFormat); return; } if (username == null || password == null) { io.getWriter().write(resp, HttpServletResponse.SC_BAD_REQUEST, - Map.of("error", "invalid_request", "error_description", "Missing username/password"), outFormat); + Map.of("error", "invalid_request", + "error_description", "Missing username/password"), outFormat); return; } - if (!authenticator.authenticate(username, password)) { - // 401 for bad creds + if (!veeamControlService.validateCredentials(username, password)) { io.getWriter().write(resp, HttpServletResponse.SC_UNAUTHORIZED, - Map.of("error", "invalid_grant", "error_description", "Invalid credentials"), outFormat); + Map.of("error", "invalid_grant", + "error_description", "Invalid credentials"), outFormat); return; } - final String effectiveScope = (scope == null) ? "ovirt-app-api" : scope; + final String effectiveScope = (scope == null) ? StringUtils.join(REQUIRED_SCOPES, " ") : scope; final long ttl = DEFAULT_TTL_SECONDS; long nowMillis = Instant.now().toEpochMilli(); long expMillis = nowMillis + ttl * 1000L; final String token; try { - token = JwtUtil.issueHs256Jwt(BearerOrBasicAuthFilter.ISSUER, username, effectiveScope, ttl, - BearerOrBasicAuthFilter.HMAC_SECRET); + token = JwtUtil.issueHs256Jwt(username, effectiveScope, ttl, HMAC_SECRET); } catch (Exception e) { io.getWriter().write(resp, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, - Map.of("error", "server_error", "error_description", "Failed to issue token"), outFormat); + Map.of("error", "server_error", + "error_description", "Failed to issue token"), outFormat); return; } @@ -128,61 +134,4 @@ public class SsoService extends ManagerBase implements RouteHandler { s = s.trim(); return s.isEmpty() ? null : s; } - - // ---------- Minimal auth helpers (replace later) ---------- - - interface PasswordAuthenticator { - boolean authenticate(String username, String password); - } - - static final class StaticPasswordAuthenticator implements PasswordAuthenticator { - StaticPasswordAuthenticator() { - } - @Override - public boolean authenticate(String username, String password) { - return VeeamControlService.Username.value().equals(username) && - VeeamControlService.Password.value().equals(password); - } - } - - // ---------- Minimal JWT HS256 without extra libs ---------- - // (If you prefer Nimbus, I can convert this to nimbus-jose-jwt; this keeps dependencies tiny.) - - static final class JwtUtil { - static String issueHs256Jwt(String issuer, String subject, String scope, long ttlSeconds, String secret) throws Exception { - long now = Instant.now().getEpochSecond(); - long exp = now + ttlSeconds; - - String headerJson = "{\"alg\":\"HS256\",\"typ\":\"JWT\"}"; - String payloadJson = - "{" - + "\"iss\":\"" + jsonEscape(issuer) + "\"," - + "\"sub\":\"" + jsonEscape(subject) + "\"," - + "\"scope\":\"" + jsonEscape(scope) + "\"," - + "\"iat\":" + now + "," - + "\"exp\":" + exp - + "}"; - - String header = b64Url(headerJson.getBytes("UTF-8")); - String payload = b64Url(payloadJson.getBytes("UTF-8")); - String signingInput = header + "." + payload; - - byte[] sig = hmacSha256(signingInput.getBytes("UTF-8"), secret.getBytes("UTF-8")); - return signingInput + "." + b64Url(sig); - } - - static byte[] hmacSha256(byte[] data, byte[] key) throws Exception { - Mac mac = Mac.getInstance("HmacSHA256"); - mac.init(new SecretKeySpec(key, "HmacSHA256")); - return mac.doFinal(data); - } - - static String b64Url(byte[] in) { - return java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(in); - } - - static String jsonEscape(String s) { - return s.replace("\\", "\\\\").replace("\"", "\\\""); - } - } } diff --git a/plugins/integrations/veeam-control-service/src/main/java/org/apache/cloudstack/veeam/utils/DataUtil.java b/plugins/integrations/veeam-control-service/src/main/java/org/apache/cloudstack/veeam/utils/DataUtil.java new file mode 100644 index 00000000000..9e0eef768d0 --- /dev/null +++ b/plugins/integrations/veeam-control-service/src/main/java/org/apache/cloudstack/veeam/utils/DataUtil.java @@ -0,0 +1,44 @@ +// 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.cloudstack.veeam.utils; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +public class DataUtil { + + public static String b64Url(byte[] in) { + return Base64.getUrlEncoder().withoutPadding().encodeToString(in); + } + + public static String jsonEscape(String s) { + return s == null ? "" : s.replace("\\", "\\\\").replace("\"", "\\\""); + } + + public static boolean constantTimeEquals(String a, String b) { + if (a == null || b == null) return false; + return constantTimeEquals(a.getBytes(StandardCharsets.UTF_8), b.getBytes(StandardCharsets.UTF_8)); + } + + public static boolean constantTimeEquals(byte[] x, byte[] y) { + if (x.length != y.length) return false; + int r = 0; + for (int i = 0; i < x.length; i++) r |= x[i] ^ y[i]; + return r == 0; + } +} diff --git a/plugins/integrations/veeam-control-service/src/main/java/org/apache/cloudstack/veeam/utils/JwtUtil.java b/plugins/integrations/veeam-control-service/src/main/java/org/apache/cloudstack/veeam/utils/JwtUtil.java new file mode 100644 index 00000000000..c4438525c34 --- /dev/null +++ b/plugins/integrations/veeam-control-service/src/main/java/org/apache/cloudstack/veeam/utils/JwtUtil.java @@ -0,0 +1,57 @@ +// 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.cloudstack.veeam.utils; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +public class JwtUtil { + public static final String ALGORITHM = "HmacSHA256"; + public static final String ISSUER = "veeam-control"; + + public static String issueHs256Jwt(String subject, String scope, long ttlSeconds, String secret) throws Exception { + long now = Instant.now().getEpochSecond(); + long exp = now + ttlSeconds; + + String headerJson = "{\"alg\":\"HS256\",\"typ\":\"JWT\"}"; + String payloadJson = + "{" + + "\"iss\":\"" + DataUtil.jsonEscape(ISSUER) + "\"," + + "\"sub\":\"" + DataUtil.jsonEscape(subject) + "\"," + + "\"scope\":\"" + DataUtil.jsonEscape(scope) + "\"," + + "\"iat\":" + now + "," + + "\"exp\":" + exp + + "}"; + + String header = DataUtil.b64Url(headerJson.getBytes(StandardCharsets.UTF_8)); + String payload = DataUtil.b64Url(payloadJson.getBytes(StandardCharsets.UTF_8)); + String signingInput = header + "." + payload; + + byte[] sig = hmacSha256(signingInput.getBytes(StandardCharsets.UTF_8), secret.getBytes(StandardCharsets.UTF_8)); + return signingInput + "." + DataUtil.b64Url(sig); + } + + public static byte[] hmacSha256(byte[] data, byte[] key) throws Exception { + final Mac mac = Mac.getInstance(ALGORITHM); + mac.init(new SecretKeySpec(key, ALGORITHM)); + return mac.doFinal(data); + } +} \ No newline at end of file