feat: add gitlab webhook verification
This commit is contained in:
@@ -1,10 +1,29 @@
|
|||||||
package com.onixbyte.deltaforceguide.config;
|
package com.onixbyte.deltaforceguide.config;
|
||||||
|
|
||||||
|
import com.onixbyte.deltaforceguide.interceptor.GitLabWebhookInterceptor;
|
||||||
import com.onixbyte.deltaforceguide.properties.AppProperties;
|
import com.onixbyte.deltaforceguide.properties.AppProperties;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||||
|
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableConfigurationProperties(AppProperties.class)
|
@EnableConfigurationProperties(AppProperties.class)
|
||||||
public class AppConfig {
|
public class AppConfig implements WebMvcConfigurer {
|
||||||
|
|
||||||
|
private final GitLabWebhookInterceptor gitLabWebhookInterceptor;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public AppConfig(
|
||||||
|
GitLabWebhookInterceptor gitLabWebhookInterceptor
|
||||||
|
) {
|
||||||
|
this.gitLabWebhookInterceptor = gitLabWebhookInterceptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void addInterceptors(InterceptorRegistry registry) {
|
||||||
|
registry.addInterceptor(gitLabWebhookInterceptor)
|
||||||
|
.addPathPatterns("/webhook/gitlab");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.onixbyte.deltaforceguide.config;
|
||||||
|
|
||||||
|
import com.onixbyte.deltaforceguide.filter.WebhookFilter;
|
||||||
|
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class FilterConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public FilterRegistrationBean<WebhookFilter> loggingFilter(WebhookFilter webhookFilter) {
|
||||||
|
var registrationBean = new FilterRegistrationBean<WebhookFilter>();
|
||||||
|
|
||||||
|
registrationBean.setFilter(webhookFilter);
|
||||||
|
registrationBean.addUrlPatterns("/webhook/*");
|
||||||
|
registrationBean.setOrder(1);
|
||||||
|
|
||||||
|
return registrationBean;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.onixbyte.deltaforceguide.config;
|
||||||
|
|
||||||
|
import com.onixbyte.deltaforceguide.properties.WebhookProperties;
|
||||||
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@EnableConfigurationProperties(WebhookProperties.class)
|
||||||
|
public class WebhookConfig {
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.onixbyte.deltaforceguide.filter;
|
||||||
|
|
||||||
|
import com.onixbyte.deltaforceguide.wrapper.RepeatedlyReadRequestWrapper;
|
||||||
|
import jakarta.servlet.*;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class WebhookFilter implements Filter {
|
||||||
|
@Override
|
||||||
|
public void doFilter(
|
||||||
|
ServletRequest request,
|
||||||
|
ServletResponse response,
|
||||||
|
FilterChain chain
|
||||||
|
) throws IOException, ServletException {
|
||||||
|
var httpRequest = (HttpServletRequest) request;
|
||||||
|
var wrappedRequest = new RepeatedlyReadRequestWrapper(httpRequest);
|
||||||
|
chain.doFilter(wrappedRequest, response);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package com.onixbyte.deltaforceguide.interceptor;
|
||||||
|
|
||||||
|
import com.onixbyte.deltaforceguide.exeption.BizException;
|
||||||
|
import com.onixbyte.deltaforceguide.properties.WebhookProperties;
|
||||||
|
import com.onixbyte.deltaforceguide.wrapper.RepeatedlyReadRequestWrapper;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import org.jspecify.annotations.NonNull;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.servlet.HandlerInterceptor;
|
||||||
|
|
||||||
|
import javax.crypto.Mac;
|
||||||
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.util.Base64;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class GitLabWebhookInterceptor implements HandlerInterceptor {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(GitLabWebhookInterceptor.class);
|
||||||
|
private static final String TOKEN_PREFIX = "whsec_";
|
||||||
|
|
||||||
|
private final WebhookProperties webhookProperties;
|
||||||
|
|
||||||
|
public GitLabWebhookInterceptor(WebhookProperties webhookProperties) {
|
||||||
|
this.webhookProperties = webhookProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean preHandle(
|
||||||
|
@NonNull HttpServletRequest request,
|
||||||
|
@NonNull HttpServletResponse response,
|
||||||
|
@NonNull Object handler
|
||||||
|
) {
|
||||||
|
if (!(request instanceof RepeatedlyReadRequestWrapper req)) {
|
||||||
|
throw new BizException(HttpStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
"Request body is not readable");
|
||||||
|
}
|
||||||
|
|
||||||
|
var webhookId = request.getHeader("webhook-id");
|
||||||
|
var webhookTimestamp = request.getHeader("webhook-timestamp");
|
||||||
|
var webhookSignature = request.getHeader("webhook-signature");
|
||||||
|
|
||||||
|
if (webhookId == null || webhookTimestamp == null || webhookSignature == null) {
|
||||||
|
log.warn("Missing webhook headers from ip={}", request.getRemoteAddr());
|
||||||
|
throw new BizException(HttpStatus.UNAUTHORIZED,
|
||||||
|
"Missing webhook verification headers");
|
||||||
|
}
|
||||||
|
|
||||||
|
var signingToken = webhookProperties.gitlab().signingToken();
|
||||||
|
if (signingToken == null || signingToken.isBlank()) {
|
||||||
|
log.debug("No GitLab signing token configured, skipping signature verification");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var body = req.getBodyString();
|
||||||
|
var signedContent = "%s.%s.%s".formatted(webhookId, webhookTimestamp, body);
|
||||||
|
|
||||||
|
try {
|
||||||
|
var decodedKey = decodeSigningToken(signingToken);
|
||||||
|
var computedDigest = computeHmacSha256(decodedKey, signedContent);
|
||||||
|
var computedSignature = "v1,%s".formatted(computedDigest);
|
||||||
|
|
||||||
|
var signatures = webhookSignature.split(" ");
|
||||||
|
var matched = false;
|
||||||
|
for (var sig : signatures) {
|
||||||
|
if (MessageDigest.isEqual(computedSignature.getBytes(StandardCharsets.UTF_8),
|
||||||
|
sig.trim().getBytes(StandardCharsets.UTF_8))) {
|
||||||
|
matched = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!matched) {
|
||||||
|
log.warn("Invalid webhook signature from ip={}", request.getRemoteAddr());
|
||||||
|
throw new BizException(HttpStatus.UNAUTHORIZED, "Invalid webhook signature");
|
||||||
|
}
|
||||||
|
} catch (BizException e) {
|
||||||
|
throw e;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Failed to verify webhook signature", e);
|
||||||
|
throw new BizException(HttpStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
"Failed to verify webhook signature");
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] decodeSigningToken(String token) {
|
||||||
|
if (!token.startsWith(TOKEN_PREFIX)) {
|
||||||
|
throw new BizException(HttpStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
"Signing token must start with " + TOKEN_PREFIX);
|
||||||
|
}
|
||||||
|
var encoded = token.substring(TOKEN_PREFIX.length());
|
||||||
|
return Base64.getDecoder().decode(encoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String computeHmacSha256(byte[] key, String data) throws Exception {
|
||||||
|
var mac = Mac.getInstance("HmacSHA256");
|
||||||
|
var secretKey = new SecretKeySpec(key, "HmacSHA256");
|
||||||
|
mac.init(secretKey);
|
||||||
|
var hash = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
|
||||||
|
return Base64.getEncoder().encodeToString(hash);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.onixbyte.deltaforceguide.manager;
|
||||||
|
|
||||||
|
import com.onixbyte.deltaforceguide.properties.GitLabWebhookProperties;
|
||||||
|
import com.onixbyte.deltaforceguide.properties.WebhookProperties;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class WebhookManager {
|
||||||
|
|
||||||
|
private final WebhookProperties webhookProperties;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public WebhookManager(WebhookProperties webhookProperties) {
|
||||||
|
this.webhookProperties = webhookProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
public GitLabWebhookProperties getGitLabWebhookProperties() {
|
||||||
|
return webhookProperties.gitlab();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package com.onixbyte.deltaforceguide.properties;
|
||||||
|
|
||||||
|
public record GitLabWebhookProperties(
|
||||||
|
String signingToken
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.onixbyte.deltaforceguide.properties;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
|
@ConfigurationProperties(prefix = "app.webhook")
|
||||||
|
public record WebhookProperties(
|
||||||
|
GitLabWebhookProperties gitlab
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.onixbyte.deltaforceguide.service;
|
||||||
|
|
||||||
|
import com.onixbyte.deltaforceguide.manager.WebhookManager;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class WebhookService {
|
||||||
|
|
||||||
|
private final WebhookManager webhookManager;
|
||||||
|
|
||||||
|
public WebhookService(WebhookManager webhookManager) {
|
||||||
|
this.webhookManager = webhookManager;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package com.onixbyte.deltaforceguide.wrapper;
|
||||||
|
|
||||||
|
import jakarta.servlet.ReadListener;
|
||||||
|
import jakarta.servlet.ServletInputStream;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletRequestWrapper;
|
||||||
|
import org.springframework.util.StreamUtils;
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
public class RepeatedlyReadRequestWrapper extends HttpServletRequestWrapper {
|
||||||
|
|
||||||
|
private final byte[] body;
|
||||||
|
|
||||||
|
public RepeatedlyReadRequestWrapper(HttpServletRequest request) throws IOException {
|
||||||
|
super(request);
|
||||||
|
this.body = StreamUtils.copyToByteArray(request.getInputStream());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ServletInputStream getInputStream() {
|
||||||
|
final var byteArrayInputStream = new ByteArrayInputStream(body);
|
||||||
|
return new ServletInputStream() {
|
||||||
|
@Override
|
||||||
|
public boolean isFinished() {
|
||||||
|
return byteArrayInputStream.available() == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isReady() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setReadListener(ReadListener readListener) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int read() throws IOException {
|
||||||
|
return byteArrayInputStream.read();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public BufferedReader getReader() throws IOException {
|
||||||
|
return new BufferedReader(new InputStreamReader(this.getInputStream(), StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getBodyString() {
|
||||||
|
return new String(body, StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user