feat: add gitlab webhook verification
This commit is contained in:
@@ -1,10 +1,29 @@
|
||||
package com.onixbyte.deltaforceguide.config;
|
||||
|
||||
import com.onixbyte.deltaforceguide.interceptor.GitLabWebhookInterceptor;
|
||||
import com.onixbyte.deltaforceguide.properties.AppProperties;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
@Configuration
|
||||
@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