mirror of https://github.com/apache/cloudstack.git
changes for allowed cidrs; refactor
Signed-off-by: Abhishek Kumar <abhishek.mrt22@gmail.com>
This commit is contained in:
parent
38c8b70cf3
commit
50403f7548
|
|
@ -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<RouteHandler> routeHandlers;
|
||||
|
||||
public VeeamControlServer(List<RouteHandler> routeHandlers) {
|
||||
public VeeamControlServer(List<RouteHandler> 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)), "/*");
|
||||
|
|
|
|||
|
|
@ -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<String> ContextPath = new ConfigKey<>("Advanced", String.class, "integration.veeam.control.context.path",
|
||||
"/ovirt-engine", "Context path for Veeam Integration REST API server", false);
|
||||
ConfigKey<String> Username = new ConfigKey<>("Advanced", String.class, "integration.veeam.control.api.username",
|
||||
ConfigKey<String> Username = new ConfigKey<>("Secure", String.class, "integration.veeam.control.api.username",
|
||||
"veeam", "Username for Basic Auth on Veeam Integration REST API server", true);
|
||||
ConfigKey<String> Password = new ConfigKey<>("Advanced", String.class, "integration.veeam.control.api.password",
|
||||
ConfigKey<String> 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<String> 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<String> 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<String> getAllowedClientCidrs();
|
||||
|
||||
boolean validateCredentials(String username, String password);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<RouteHandler> routeHandlers;
|
||||
|
||||
private VeeamControlServer veeamControlServer;
|
||||
private SingleCache<List<String>> allowedClientCidrsCache;
|
||||
|
||||
protected List<String> getAllowedClientCidrsInternal() {
|
||||
String allowedClientCidrsStr = AllowedClientCidrs.value();
|
||||
if (StringUtils.isBlank(allowedClientCidrsStr)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<String> 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<RouteHandler> getRouteHandlers() {
|
||||
return routeHandlers;
|
||||
|
|
@ -37,9 +63,21 @@ public class VeeamControlServiceImpl extends ManagerBase implements VeeamControl
|
|||
this.routeHandlers = routeHandlers;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> 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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String> 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String> 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<String, Object> 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("<html><head><title>Error</title></head><body>Unauthorized</body></html>");
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String> 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("\"", "\\\"");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue